import { useEffect, useMemo, useState } from 'react' import { addTagToVerse, getLibraryState, searchVerses } from './studyClient' import type { ReactNode } from 'react' import type { Verse } from './types' import './App.css' const tagColors = ['#2f6f5e', '#735c0f', '#8a3ffc', '#b42318', '#245ba7'] const pageSize = 34 type ViewMode = 'page' | 'study' | 'notes' | 'compare' type StrongDisplayMode = 'off' | 'highlight' | 'numbers' function reference(verse: Verse) { return `${verse.book} ${verse.chapter}:${verse.verse}` } function App() { const [viewMode, setViewMode] = useState('notes') const [query, setQuery] = useState('') const [libraryVerses, setLibraryVerses] = useState([]) const [verses, setVerses] = useState([]) const [selectedId, setSelectedId] = useState(null) const [pageStartId, setPageStartId] = useState(null) const [selectedStrong, setSelectedStrong] = useState(null) const [strongDisplay, setStrongDisplay] = useState('highlight') const [tagInput, setTagInput] = useState('') const [noteDraft, setNoteDraft] = useState('') const [loading, setLoading] = useState(true) const [error, setError] = useState(null) async function loadLibrary() { setError(null) const state = await getLibraryState() setLibraryVerses(state.verses) setVerses(state.verses) const firstId = state.verses[0]?.id ?? null setSelectedId((current) => current ?? firstId) setPageStartId((current) => current ?? firstId) } async function runSearch(nextQuery = query) { setError(null) const trimmed = nextQuery.trim() setSelectedStrong(/^[gh]\d+$/i.test(trimmed) ? trimmed.toUpperCase() : null) const results = await searchVerses(nextQuery) setVerses(results) const firstResult = results[0] setSelectedId(firstResult?.id ?? null) setPageStartId(firstResult ? findPageStartId(firstResult) : null) } async function addTag() { const label = tagInput.trim() if (!selectedId || !label) return const color = tagColors[Math.abs(label.length) % tagColors.length] const updated = await addTagToVerse(selectedId, label, color) setLibraryVerses((current) => current.map((verse) => (verse.id === updated.id ? updated : verse)), ) setVerses((current) => current.map((verse) => (verse.id === updated.id ? updated : verse)), ) setTagInput('') } useEffect(() => { loadLibrary() .catch((reason) => setError(String(reason))) .finally(() => setLoading(false)) }, []) const selectedVerse = useMemo( () => libraryVerses.find((verse) => verse.id === selectedId) ?? verses.find((verse) => verse.id === selectedId) ?? verses[0], [libraryVerses, selectedId, verses], ) const selectedStrongEntry = useMemo(() => { if (!selectedVerse?.strongs.length) return null return ( selectedVerse.strongs.find((entry) => entry.strong === selectedStrong) ?? selectedVerse.strongs[0] ) }, [selectedStrong, selectedVerse]) const pageAnchors = useMemo(() => { const source = libraryVerses.length ? libraryVerses : verses const anchors: Verse[] = [] let index = 0 while (index < source.length) { const book = source[index].book let bookVerseIndex = 0 while (index < source.length && source[index].book === book) { if (bookVerseIndex % pageSize === 0) { anchors.push(source[index]) } bookVerseIndex += 1 index += 1 } } return anchors }, [libraryVerses, verses]) const pageVerses = useMemo(() => { if (!selectedVerse) return [] const source = libraryVerses.length ? libraryVerses : verses const pageStartIndex = source.findIndex((verse) => verse.id === pageStartId) const selectedIndex = source.findIndex((verse) => verse.id === selectedVerse.id) const start = Math.max(0, pageStartIndex >= 0 ? pageStartIndex : selectedIndex) const book = source[start]?.book return source .slice(start, start + pageSize) .filter((verse) => verse.book === book) }, [libraryVerses, pageStartId, selectedVerse, verses]) const pageRange = pageVerses.length ? `${reference(pageVerses[0])} - ${reference(pageVerses[pageVerses.length - 1])}` : 'No page loaded' const selectedFootnotes = selectedVerse?.footnotes ?? [] const pageFootnotes = useMemo( () => pageVerses.flatMap((verse) => verse.footnotes ?? []), [pageVerses], ) const books = useMemo( () => Array.from(new Set(libraryVerses.map((verse) => verse.book))), [libraryVerses], ) const chapters = useMemo(() => { if (!selectedVerse) return [] return Array.from( new Set( libraryVerses .filter((verse) => verse.book === selectedVerse.book) .map((verse) => verse.chapter), ), ) }, [libraryVerses, selectedVerse]) const verseNumbers = useMemo(() => { if (!selectedVerse) return [] return libraryVerses .filter( (verse) => verse.book === selectedVerse.book && verse.chapter === selectedVerse.chapter, ) .map((verse) => verse.verse) }, [libraryVerses, selectedVerse]) function searchStrong(strong: string) { setQuery(strong) setSelectedStrong(strong) runSearch(strong).catch((reason) => setError(String(reason))) } function jumpTo(book: string, chapter: number, verse: number) { const next = libraryVerses.find( (entry) => entry.book === book && entry.chapter === chapter && entry.verse === verse, ) ?? libraryVerses.find((entry) => entry.book === book && entry.chapter === chapter) ?? libraryVerses.find((entry) => entry.book === book) if (!next) return setQuery('') setSelectedStrong(null) setVerses(libraryVerses) setSelectedId(next.id) setPageStartId(findPageStartId(next)) } function shiftPage(direction: -1 | 1) { if (!selectedVerse) return const currentStartIndex = pageAnchors.findIndex((verse) => verse.id === pageStartId) const selectedPageStartId = findPageStartId(selectedVerse) const selectedIndex = pageAnchors.findIndex((verse) => verse.id === selectedPageStartId) const index = currentStartIndex >= 0 ? currentStartIndex : selectedIndex const next = pageAnchors[Math.min(pageAnchors.length - 1, Math.max(0, index + direction))] if (!next) return setPageStartId(next.id) setSelectedId(next.id) setSelectedStrong(null) } function findPageStartId(verse: Verse) { const source = libraryVerses.length ? libraryVerses : verses const index = source.findIndex((entry) => entry.id === verse.id) if (index < 0) return pageStartId ?? verse.id let anchor = source[index] for (const pageAnchor of pageAnchors) { const anchorIndex = source.findIndex((entry) => entry.id === pageAnchor.id) if (anchorIndex > index) break if (pageAnchor.book === verse.book) { anchor = pageAnchor } } return anchor.id } function selectVerse(verse: Verse) { setSelectedId(verse.id) setSelectedStrong(null) } function selectStrong(verse: Verse, strong: string) { setSelectedId(verse.id) setSelectedStrong(strong) } function renderVerseText(verse: Verse) { if (!verse.strongs.length || strongDisplay === 'off') return verse.text const parts: ReactNode[] = [] let cursor = 0 const lowerText = verse.text.toLowerCase() const sortedLinks = [...verse.strongs].sort((left, right) => left.position - right.position) sortedLinks.forEach((entry) => { const surface = entry.surface.trim() if (!surface) return const index = lowerText.indexOf(surface.toLowerCase(), cursor) if (index < 0) return if (index > cursor) { parts.push(verse.text.slice(cursor, index)) } const linkedText = verse.text.slice(index, index + surface.length) const isActive = entry.strong === selectedStrong const showCode = strongDisplay === 'numbers' || isActive parts.push( { event.stopPropagation() selectStrong(verse, entry.strong) }} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() selectStrong(verse, entry.strong) } }} > {linkedText} {showCode ? ( {entry.strong} ) : null} , ) cursor = index + surface.length }) if (cursor < verse.text.length) { parts.push(verse.text.slice(cursor)) } return parts.length ? parts : verse.text } function renderOriginalLanguageLine() { if (!selectedVerse?.strongs.length) return null return (

Lemma Line

{selectedVerse.strongs.map((entry) => ( ))}

) } function renderBiblePage(label = 'KJV') { if (!selectedVerse) return null return (
{label} {reference(selectedVerse)} {verses.length} verses loaded
{pageVerses.map((verse) => (

selectVerse(verse)} > {verse.verse} {renderVerseText(verse)}

))}
{pageRange}
) } function renderStudyMargin() { if (!selectedVerse) return null return ( ) } function renderFootnoteStrip() { const visibleFootnotes = selectedFootnotes.length ? selectedFootnotes : pageFootnotes.slice(0, 4) return (
Footnotes {visibleFootnotes.length ? (
{visibleFootnotes.map((note) => (

{note.reference} {note.text}

))}
) : ( No footnotes for the selected verse. )}
) } return (
{loading ? (
Loading local library...
) : error ? (
{error}
) : selectedVerse ? ( <>
King James Version

{pageRange}

{ event.preventDefault() runSearch().catch((reason) => setError(String(reason))) }} > setQuery(event.target.value)} placeholder='Search KJV, reference, or Strong's' />
{viewMode === 'compare' ? (
{renderBiblePage('KJV')}
Second Translation Not loaded

Add another translation to compare side by side.

This pane is reserved for the next LibreBible translation package. It will use the same book, chapter, and verse position as the KJV page.

) : (
{renderBiblePage('KJV')} {viewMode !== 'page' ? renderStudyMargin() : null} {renderFootnoteStrip()}
)} {viewMode === 'notes' ? (