Reader: verse study panel (commentaries, people & places) and NET Bible
Build and Push Docker Image / build-and-push (push) Failing after 20s
Build and Push Docker Image / build-and-push (push) Failing after 20s
Adds a per-verse panel with seven HelloAO commentaries and Theographic people/places/events, and NET as a fifth Reader translation. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
84a5bb8838
commit
9e8420a1ae
4 files changed
+261
-2
No files matched your search
@@ -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 (
|
||||
<div>
|
||||
<label className="text-xs text-slate-600">
|
||||
Commentary{' '}
|
||||
<select value={source.id} onChange={(e) => changeSource(e.target.value)}
|
||||
className="rounded-lg border border-slate-300 bg-white px-2 py-1 text-xs">
|
||||
{COMMENTARIES.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
{status === 'loading' && <p className="text-xs text-slate-500">Loading…</p>}
|
||||
{status === 'empty' && <p className="text-xs text-slate-500">{source.name} has no notes for this chapter.</p>}
|
||||
{status === 'ready' && !entry && <p className="text-xs text-slate-500">No notes at or before this verse in {source.name}.</p>}
|
||||
{status === 'ready' && entry && (
|
||||
<>
|
||||
{entry.number !== verse && (
|
||||
<p className="mb-1 text-[11px] text-slate-500">Notes on the passage starting at verse {entry.number}.</p>
|
||||
)}
|
||||
<div className="max-h-96 overflow-y-auto whitespace-pre-line rounded-xl bg-white p-3 text-[13px] leading-relaxed text-slate-700">
|
||||
{entry.text}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-[10px] text-slate-400">{source.credit}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonDetails({ id }) {
|
||||
const { status, data } = useAsync(() => loadPerson(id), [id]);
|
||||
if (status === 'loading') return <p className="mt-1 text-xs text-slate-500">Loading…</p>;
|
||||
if (status === 'empty') return <p className="mt-1 text-xs text-slate-500">No details available.</p>;
|
||||
const era = (y) => `${Math.abs(y)}${y < 0 ? ' BC' : ' AD'}`;
|
||||
return (
|
||||
<div className="mt-1 space-y-1 text-xs leading-relaxed text-slate-600">
|
||||
{(data.description ?? []).map((d, i) => <p key={i}>{d}</p>)}
|
||||
{!data.description?.length && <p>No description available.</p>}
|
||||
{data.minYear != null && (
|
||||
<p className="text-slate-400">
|
||||
Lived approx. {era(data.minYear)}{data.maxYear != null && data.maxYear !== data.minYear ? ` – ${era(data.maxYear)}` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonCard({ person }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<li className="rounded-xl bg-white px-3 py-2">
|
||||
<button type="button" onClick={() => setOpen((o) => !o)} className="w-full text-left text-sm font-semibold text-slate-800">
|
||||
{person.name} <span className="text-xs font-normal text-slate-400">{open ? '▾' : '▸'}</span>
|
||||
</button>
|
||||
{open && <PersonDetails id={person.id} />}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
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>;
|
||||
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 <p className="text-xs text-slate-500">Nothing tagged on verse {verse}.</p>;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{people.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">People</p>
|
||||
<ul className="space-y-1">{people.map((p) => <PersonCard key={p.id} person={p} />)}</ul>
|
||||
</div>
|
||||
)}
|
||||
{places.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">Places</p>
|
||||
<ul className="space-y-1">
|
||||
{places.map((p) => (
|
||||
<li key={p.id} className="flex items-center justify-between gap-2 rounded-xl bg-white px-3 py-2 text-sm text-slate-800">
|
||||
<span>{p.name}{p.featureType && <span className="ml-1 text-xs text-slate-400">{p.featureType}</span>}</span>
|
||||
{p.latitude != null && p.longitude != null && (
|
||||
<a href={`https://www.openstreetmap.org/?mlat=${p.latitude}&mlon=${p.longitude}#map=8/${p.latitude}/${p.longitude}`}
|
||||
target="_blank" rel="noopener noreferrer" className="text-xs font-semibold text-sky-600 hover:underline">
|
||||
Map ↗
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{events.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">Events</p>
|
||||
<ul className="space-y-1">
|
||||
{events.map((e) => (
|
||||
<li key={e.id} className="rounded-xl bg-white px-3 py-2 text-sm text-slate-800">
|
||||
{e.name}{e.startDate && <span className="ml-1 text-xs text-slate-400">approx. AD {e.startDate}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[10px] text-slate-400">
|
||||
People, places and events: Theographic Bible Metadata, licensed CC BY-SA 4.0.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StudyPanel({ book, chapter, verse, onClose }) {
|
||||
const [tab, setTab] = useState('commentary');
|
||||
const tabBtn = (id, label) => (
|
||||
<button type="button" onClick={() => setTab(id)}
|
||||
className={`rounded-lg px-3 py-1 text-xs font-semibold ${tab === id ? 'bg-slate-900 text-white' : 'bg-white text-slate-600 hover:bg-slate-100'}`}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<div className="mt-2 mb-1 ml-6 rounded-2xl border border-indigo-100 bg-indigo-50 p-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="flex gap-1">
|
||||
{tabBtn('commentary', 'Commentary')}
|
||||
{tabBtn('people', 'People & Places')}
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="rounded p-1 text-xs text-slate-500 hover:bg-white" title="Close">✕</button>
|
||||
</div>
|
||||
{tab === 'commentary'
|
||||
? <CommentaryTab book={book} chapter={chapter} verse={verse} />
|
||||
: <PeoplePlacesTab book={book} chapter={chapter} verse={verse} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<entries | null>
|
||||
const theographicCache = new Map(); // "BOOK/ch" -> Promise<{people, places, events} | null>
|
||||
const personCache = new Map(); // id -> Promise<person | null>
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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() {
|
||||
🎨
|
||||
</button>
|
||||
)}
|
||||
<button type="button"
|
||||
onClick={() => 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">
|
||||
📚
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => 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"
|
||||
@@ -663,6 +671,10 @@ export default function ReaderPage() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{studyVerse === verse.number && (
|
||||
<StudyPanel book={readerBookAbbrev} chapter={readerChapter} verse={verse.number} onClose={() => setStudyVerse(null)} />
|
||||
)}
|
||||
|
||||
{readerShowCrossRefs && crossRefs && (
|
||||
<div className="mt-1 ml-6 flex flex-wrap gap-1">
|
||||
{crossRefs.slice(0, 8).map((ref, i) => {
|
||||
|
||||
Reference in new issue
Block a user