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 && ( +
    +

    People

    + +
    + )} + {places.length > 0 && ( +
    +

    Places

    + +
    + )} + {events.length > 0 && ( +
    +

    Events

    + +
    + )} +

    + 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() { 🎨 )} +