diff --git a/src/components/StudyPanel.jsx b/src/components/StudyPanel.jsx
new file mode 100644
index 0000000..b634d73
--- /dev/null
+++ b/src/components/StudyPanel.jsx
@@ -0,0 +1,159 @@
+// Inline study panel shown under a verse in the Reader: commentary from several
+// public-domain / open sources, plus the people, places and events tied to the verse.
+import { useState } from 'react';
+import {
+ COMMENTARIES, loadCommentary, loadTheographic, loadPerson, pickCommentaryEntry, useAsync,
+} from '../hooks/useVerseStudy.js';
+
+function CommentaryTab({ book, chapter, verse }) {
+ const [sourceId, setSourceId] = useState(() => {
+ try { return localStorage.getItem('reader-commentary') || COMMENTARIES[0].id; } catch { return COMMENTARIES[0].id; }
+ });
+ const source = COMMENTARIES.find((c) => c.id === sourceId) ?? COMMENTARIES[0];
+ const { status, data } = useAsync(() => loadCommentary(source.id, book, chapter), [source.id, book, chapter]);
+ const entry = pickCommentaryEntry(data, verse);
+
+ const changeSource = (id) => {
+ setSourceId(id);
+ try { localStorage.setItem('reader-commentary', id); } catch {}
+ };
+
+ return (
+
+
+
+ {status === 'loading' &&
Loading…
}
+ {status === 'empty' &&
{source.name} has no notes for this chapter.
}
+ {status === 'ready' && !entry &&
No notes at or before this verse in {source.name}.
}
+ {status === 'ready' && entry && (
+ <>
+ {entry.number !== verse && (
+
Notes on the passage starting at verse {entry.number}.
+ )}
+
+ {entry.text}
+
+ >
+ )}
+
+
{source.credit}
+
+ );
+}
+
+function PersonDetails({ id }) {
+ const { status, data } = useAsync(() => loadPerson(id), [id]);
+ if (status === 'loading') return Loading…
;
+ if (status === 'empty') return No details available.
;
+ const era = (y) => `${Math.abs(y)}${y < 0 ? ' BC' : ' AD'}`;
+ return (
+
+ {(data.description ?? []).map((d, i) =>
{d}
)}
+ {!data.description?.length &&
No description available.
}
+ {data.minYear != null && (
+
+ Lived approx. {era(data.minYear)}{data.maxYear != null && data.maxYear !== data.minYear ? ` – ${era(data.maxYear)}` : ''}
+
+ )}
+
+ );
+}
+
+function PersonCard({ person }) {
+ const [open, setOpen] = useState(false);
+ return (
+
+
+ {open && }
+
+ );
+}
+
+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.
;
+ const here = (list) => list.filter((x) => x.verses?.includes(verse));
+ // Theographic lists some places twice (variant spellings) — collapse by name.
+ const places = [...new Map(here(data.places).map((p) => [p.name, p])).values()];
+ const people = here(data.people);
+ const events = here(data.events);
+ if (!people.length && !places.length && !events.length) {
+ return Nothing tagged on verse {verse}.
;
+ }
+ return (
+
+ {people.length > 0 && (
+
+ )}
+ {places.length > 0 && (
+
+
Places
+
+ {places.map((p) => (
+ -
+ {p.name}{p.featureType && {p.featureType}}
+ {p.latitude != null && p.longitude != null && (
+
+ Map ↗
+
+ )}
+
+ ))}
+
+
+ )}
+ {events.length > 0 && (
+
+
Events
+
+ {events.map((e) => (
+ -
+ {e.name}{e.startDate && approx. AD {e.startDate}}
+
+ ))}
+
+
+ )}
+
+ People, places and events: Theographic Bible Metadata, licensed CC BY-SA 4.0.
+
+
+ );
+}
+
+export default function StudyPanel({ book, chapter, verse, onClose }) {
+ const [tab, setTab] = useState('commentary');
+ const tabBtn = (id, label) => (
+
+ );
+ return (
+
+
+
+ {tabBtn('commentary', 'Commentary')}
+ {tabBtn('people', 'People & Places')}
+
+
+
+ {tab === 'commentary'
+ ?
+ :
}
+
+ );
+}
diff --git a/src/hooks/useBibleReader.js b/src/hooks/useBibleReader.js
index 7899d43..2f85a8b 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: '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,
notice: 'Holy Bible, New Living Translation, Copyright © 2014, Tyndale House Publishers. All rights reserved. tyndale.com' },
{ id: 'CSB', name: 'Christian Standard Bible', licensed: true,
@@ -120,8 +122,9 @@ export function useBibleReader(currentPage) {
let verses;
let copyright = '';
let totalChapters = CHAPTER_COUNTS[bookAbbrev] ?? chapterNumber;
- if (translation === 'BSB') {
- const res = await fetch(`https://bible.helloao.org/api/BSB/${bookAbbrev}/${chapterNumber}.json`);
+ const free = READER_TRANSLATIONS.find((t) => t.id === translation && !t.licensed);
+ if (free) {
+ const res = await fetch(`https://bible.helloao.org/api/${free.helloao ?? free.id}/${bookAbbrev}/${chapterNumber}.json`);
if (!res.ok) throw new Error('Unable to load chapter.');
const data = await res.json();
verses = parseBibleChapter(data);
diff --git a/src/hooks/useVerseStudy.js b/src/hooks/useVerseStudy.js
new file mode 100644
index 0000000..5304417
--- /dev/null
+++ b/src/hooks/useVerseStudy.js
@@ -0,0 +1,85 @@
+// Free study data from HelloAO: public-domain commentaries and the Theographic
+// people/places/events dataset. Both are keyed by book + chapter, so results are
+// cached per chapter for the rest of the visit.
+import { useEffect, useState } from 'react';
+import { renderVerseContent } from '../utils/bibleUtils.jsx';
+
+const API = 'https://bible.helloao.org/api';
+
+export const COMMENTARIES = [
+ { id: 'matthew-henry', name: 'Matthew Henry', credit: 'Matthew Henry Bible Commentary (public domain).' },
+ { id: 'john-gill', name: 'John Gill', credit: 'John Gill Bible Commentary (public domain).' },
+ { id: 'jamieson-fausset-brown', name: 'Jamieson-Fausset-Brown', credit: 'Jamieson-Fausset-Brown Bible Commentary (public domain).' },
+ { id: 'tyndale', name: 'Tyndale Study Notes', credit: 'Tyndale Open Study Notes © Tyndale House Foundation, licensed CC BY-SA 4.0.' },
+ { id: 'adam-clarke', name: 'Adam Clarke', credit: 'Adam Clarke Bible Commentary (public domain).' },
+ { id: 'john-calvin', name: 'John Calvin', credit: "John Calvin's Commentaries (public domain)." },
+ { id: 'keil-delitzsch', name: 'Keil & Delitzsch (OT)', credit: 'Keil & Delitzsch Old Testament Commentary (public domain).' },
+];
+
+const commentaryCache = new Map(); // "id/BOOK/ch" -> Promise
+const theographicCache = new Map(); // "BOOK/ch" -> Promise<{people, places, events} | null>
+const personCache = new Map(); // id -> Promise
+
+const getJson = (url) => fetch(url).then((r) => (r.ok ? r.json() : null)).catch(() => null);
+
+// Resolves to [{ number, text }] sorted by verse, or null when the commentary
+// doesn't cover that book/chapter.
+export function loadCommentary(id, book, chapter) {
+ const key = `${id}/${book}/${chapter}`;
+ if (!commentaryCache.has(key)) {
+ commentaryCache.set(key, getJson(`${API}/c/${id}/${book}/${chapter}.json`).then((data) => {
+ const items = data?.chapter?.content;
+ if (!Array.isArray(items)) return null;
+ return items
+ .filter((it) => it?.type === 'verse')
+ .map((it) => ({ number: it.number, text: renderVerseContent(it.content).replace(/[ \t]+\n/g, '\n').trim() }))
+ .filter((it) => it.text)
+ .sort((a, b) => a.number - b.number);
+ }));
+ }
+ return commentaryCache.get(key);
+}
+
+export function loadTheographic(book, chapter) {
+ const key = `${book}/${chapter}`;
+ if (!theographicCache.has(key)) {
+ theographicCache.set(key, getJson(`${API}/d/theographic/${book}/${chapter}.json`).then((data) => (
+ data?.chapter ? { people: data.chapter.people ?? [], places: data.chapter.places ?? [], events: data.chapter.events ?? [] } : null
+ )));
+ }
+ return theographicCache.get(key);
+}
+
+export function loadPerson(id) {
+ if (!personCache.has(id)) {
+ personCache.set(id, getJson(`${API}/d/theographic/people/${id}.json`).then((d) => d?.person ?? null));
+ }
+ return personCache.get(id);
+}
+
+// For verse N, the commentary entry that starts at N, or otherwise the closest
+// earlier one (several commentaries write on whole passages, not each verse).
+export function pickCommentaryEntry(entries, verse) {
+ if (!entries?.length) return null;
+ let best = null;
+ for (const e of entries) {
+ if (e.number <= verse) best = e;
+ else break;
+ }
+ return best;
+}
+
+// Small state wrapper: { status: 'loading' | 'ready' | 'empty', data }.
+export function useAsync(loader, deps) {
+ const [state, setState] = useState({ status: 'loading', data: null });
+ useEffect(() => {
+ let active = true;
+ setState({ status: 'loading', data: null });
+ loader().then((data) => {
+ if (active) setState({ status: data ? 'ready' : 'empty', data });
+ });
+ return () => { active = false; };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, deps);
+ return state;
+}
diff --git a/src/pages/ReaderPage.jsx b/src/pages/ReaderPage.jsx
index 9f94626..4f5abff 100644
--- a/src/pages/ReaderPage.jsx
+++ b/src/pages/ReaderPage.jsx
@@ -6,6 +6,7 @@ import AudioPlayer from '../components/AudioPlayer.jsx';
import { bookOptions, BookmarkIcon, CopyIcon } from '../utils/bibleUtils.jsx';
import { getPlan, getPlanSchedule, findDayIndexForRef } from '../utils/readingPlans.js';
import DrawCanvas from './DrawCanvas.jsx';
+import StudyPanel from '../components/StudyPanel.jsx';
import { READER_TRANSLATIONS } from '../hooks/useBibleReader.js';
const NO_STROKES = []; // stable fallback so DrawCanvas doesn't redraw every render
@@ -102,6 +103,7 @@ export default function ReaderPage() {
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)
// Whole-Bible search: lowercase the ~31k-verse index once (not per keystroke),
// and let typing stay responsive while results catch up via useDeferredValue.
@@ -654,6 +656,12 @@ export default function ReaderPage() {
🎨
)}
+