From c30405b42d775fb70d87ecb330b17d6619048b58 Mon Sep 17 00:00:00 2001 From: nmemmert Date: Tue, 23 Jun 2026 08:01:03 -0400 Subject: [PATCH] Enhance Bible reader with interlinear audio, bookmarks, cross-refs, search, and font size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add πŸ”Š Speak buttons on Greek/Hebrew word cards in both the reader interlinear panel and study-side word entries (Web Speech API, he-IL / el-GR) - Click verse number to expand inline interlinear panel with original script, transliteration, gloss, parsing, and Strong's number - Verse bookmarking with 5 highlight colors, persisted to localStorage - πŸ”— Cross-Refs toggle fetches open-cross-ref dataset and shows amber reference chips per verse - πŸ” In-chapter search filters verses live with yellow keyword highlights - S/M/L/XL font size controls - πŸ“‹ Copy verse button outputs formatted citation (Book Ch:V BSB β€” text) Co-Authored-By: Claude Sonnet 4.6 --- src/App.jsx | 373 +++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 324 insertions(+), 49 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index b7725a5..fc30d2f 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -901,6 +901,20 @@ const App = () => { const [readerTotalChapters, setReaderTotalChapters] = useState(1); const [readerLoading, setReaderLoading] = useState(false); const [readerError, setReaderError] = useState(''); + const [readerInterlinear, setReaderInterlinear] = useState(null); // full book interlinear + const [readerInterlinearLoading, setReaderInterlinearLoading] = useState(false); + const [readerSelectedVerse, setReaderSelectedVerse] = useState(null); // verse number with open interlinear panel + const _readerInterlinearCacheRef = useRef({}); + const [readerFontSize, setReaderFontSize] = useState(1); // em multiplier: 0.875 | 1 | 1.125 | 1.25 + const [readerBookmarks, setReaderBookmarks] = useState(() => { + try { return JSON.parse(localStorage.getItem('reader-bookmarks') || '{}'); } catch { return {}; } + }); + const [readerCrossRefs, setReaderCrossRefs] = useState(null); + const [readerCrossRefsLoading, setReaderCrossRefsLoading] = useState(false); + const [readerShowCrossRefs, setReaderShowCrossRefs] = useState(false); + const _readerCrossRefCacheRef = useRef({}); + const [readerSearch, setReaderSearch] = useState(''); + const [readerSearchActive, setReaderSearchActive] = useState(false); const [audioBook, setAudioBook] = useState(bookOptions[0].abbrev); const [audioNarrator, setAudioNarrator] = useState('souer'); const [audioState, setAudioState] = useState({ status: 'idle', chapter: 0, total: 0 }); @@ -1101,16 +1115,122 @@ const App = () => { }; const readerGoToPreviousChapter = () => { - if (readerChapter > 1) setReaderChapter((c) => c - 1); + if (readerChapter > 1) { setReaderChapter((c) => c - 1); setReaderSelectedVerse(null); setReaderCrossRefs(null); setReaderSearch(''); setReaderSearchActive(false); } }; const readerGoToNextChapter = () => { - if (readerChapter < readerTotalChapters) setReaderChapter((c) => c + 1); + if (readerChapter < readerTotalChapters) { setReaderChapter((c) => c + 1); setReaderSelectedVerse(null); setReaderCrossRefs(null); setReaderSearch(''); setReaderSearchActive(false); } }; const handleReaderBookChange = (abbrev) => { setReaderBookAbbrev(abbrev); setReaderChapter(1); + setReaderSelectedVerse(null); + setReaderInterlinear(null); + setReaderCrossRefs(null); + setReaderSearch(''); + setReaderSearchActive(false); + }; + + const loadReaderInterlinear = async (bookAbbrev) => { + if (_readerInterlinearCacheRef.current[bookAbbrev]) { + setReaderInterlinear(_readerInterlinearCacheRef.current[bookAbbrev]); + return; + } + setReaderInterlinearLoading(true); + try { + const res = await fetch(`/interlinear/${bookAbbrev}.json`); + if (!res.ok) throw new Error('no interlinear'); + const data = await res.json(); + _readerInterlinearCacheRef.current[bookAbbrev] = data; + setReaderInterlinear(data); + } catch { + setReaderInterlinear(null); + } finally { + setReaderInterlinearLoading(false); + } + }; + + useEffect(() => { + if (currentPage !== 'reader') return; + loadReaderInterlinear(readerBookAbbrev); + setReaderSelectedVerse(null); + }, [currentPage, readerBookAbbrev]); + + useEffect(() => { + if (currentPage !== 'reader' || !readerShowCrossRefs) return; + loadReaderCrossRefs(readerBookAbbrev, readerChapter); + }, [currentPage, readerBookAbbrev, readerChapter, readerShowCrossRefs]); + + const speakOriginalWord = (word, strongsNum) => { + if (!window.speechSynthesis) return; + window.speechSynthesis.cancel(); + const lang = strongsNum?.startsWith('H') ? 'he-IL' : 'el-GR'; + const utt = new SpeechSynthesisUtterance(word); + utt.lang = lang; + // prefer a matching voice if available + const voices = window.speechSynthesis.getVoices(); + const match = voices.find((v) => v.lang.startsWith(lang.split('-')[0])); + if (match) utt.voice = match; + window.speechSynthesis.speak(utt); + }; + + // Reader helpers: bookmarks, cross-refs, copy verse + const BOOKMARK_COLORS = ['#fef08a', '#bbf7d0', '#bfdbfe', '#fecaca', '#e9d5ff']; + + const toggleReaderBookmark = (verseKey) => { + setReaderBookmarks((prev) => { + const next = { ...prev }; + if (next[verseKey]) { + delete next[verseKey]; + } else { + const usedColors = Object.values(next); + const color = BOOKMARK_COLORS.find((c) => !usedColors.includes(c)) ?? BOOKMARK_COLORS[0]; + next[verseKey] = color; + } + try { localStorage.setItem('reader-bookmarks', JSON.stringify(next)); } catch {} + return next; + }); + }; + + const cycleBookmarkColor = (verseKey) => { + setReaderBookmarks((prev) => { + const cur = prev[verseKey]; + if (!cur) return prev; + const idx = BOOKMARK_COLORS.indexOf(cur); + const next = { ...prev, [verseKey]: BOOKMARK_COLORS[(idx + 1) % BOOKMARK_COLORS.length] }; + try { localStorage.setItem('reader-bookmarks', JSON.stringify(next)); } catch {} + return next; + }); + }; + + const loadReaderCrossRefs = async (bookAbbrev, chapterNumber) => { + const cacheKey = `${bookAbbrev}/${chapterNumber}`; + if (_readerCrossRefCacheRef.current[cacheKey]) { + setReaderCrossRefs(_readerCrossRefCacheRef.current[cacheKey]); + return; + } + setReaderCrossRefsLoading(true); + try { + const res = await fetch(`https://bible.helloao.org/api/d/open-cross-ref/${bookAbbrev}/${chapterNumber}.json`); + if (!res.ok) throw new Error('no cross-refs'); + const data = await res.json(); + const byVerse = {}; + for (const item of data.chapter?.content ?? []) { + if (item.references?.length) byVerse[item.verse] = item.references; + } + _readerCrossRefCacheRef.current[cacheKey] = byVerse; + setReaderCrossRefs(byVerse); + } catch { + setReaderCrossRefs(null); + } finally { + setReaderCrossRefsLoading(false); + } + }; + + const copyVerse = (bookName, chapterNum, verseNum, text) => { + const citation = `${bookName} ${chapterNum}:${verseNum} BSB β€” ${text}`; + navigator.clipboard?.writeText(citation); }; // --------------------------------------------------------------------------- @@ -3103,7 +3223,8 @@ const restoreRemoteProject = async (id) => {
-
+ {/* Navigation + tools bar */} +
- -
-
+ {/* Tool strip: font size Β· cross-refs Β· search */} +
+ {/* Font size */} + Text size + {[['S', 0.875], ['M', 1], ['L', 1.125], ['XL', 1.25]].map(([label, size]) => ( + + ))} +
+ {/* Cross-refs toggle */} + +
+ {/* In-chapter search */} + {readerSearchActive ? ( +
+ setReaderSearch(e.target.value)} + placeholder="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" + /> + +
+ ) : ( + + )} +
+ + {/* Audio player */} +

Listen to this chapter

@@ -3158,60 +3313,168 @@ const restoreRemoteProject = async (id) => {

- 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"> {readerAudioState.status === 'playing' || readerAudioState.status === 'paused' ? ( <> - - ) : ( - )}
+ {/* Verses */}
-

+

{readerBook?.name} {readerChapter} (BSB)

+ {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 && ( -
- {readerVerses.map((verse) => ( -

- {verse.number} - {verse.text} -

- ))} -
- )} + {!readerLoading && !readerError && (() => { + const searchLower = 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]; + + const highlightText = (text) => { + if (!searchLower) return text; + const idx = text.toLowerCase().indexOf(searchLower); + if (idx === -1) return text; + return ( + <> + {text.slice(0, idx)} + {text.slice(idx, idx + searchLower.length)} + {text.slice(idx + searchLower.length)} + + ); + }; + + return ( +
+
+ {/* Verse number / interlinear toggle */} + + {/* Verse text */} +

{highlightText(verse.text)}

+ {/* Action icons β€” visible on hover */} + + + {bmColor && ( + + )} + + +
+ + {/* Cross-references */} + {readerShowCrossRefs && crossRefs && ( +
+ {crossRefs.slice(0, 8).map((ref, i) => { + const label = formatCrossRef(ref); + return ( + + {label} + + ); + })} +
+ )} + + {/* Interlinear panel */} + {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 && ( + + )} +
+ ); + })} +
+
+ )} +
+ ); + })} +
+ ); + })()}
@@ -4076,7 +4339,19 @@ const restoreRemoteProject = async (id) => { ) : null}