import { useEffect, useMemo, useRef, useState } from 'react' import { addTagToVerse, getCommentaryForVerse, getDictionaryEntriesForTerms, getLibraryState, searchVerses, } from './studyClient' import type { DragEvent, ReactNode } from 'react' import type { CommentaryEntry, DictionaryEntry, Verse } from './types' import './App.css' const tagColors = ['#2f6f5e', '#735c0f', '#8a3ffc', '#b42318', '#245ba7'] const pageSize = 34 const sessionStorageKey = 'libre-study.sessions.v1' const activeSessionStorageKey = 'libre-study.active-session.v1' const studySettingsStorageKey = 'libre-study.study-settings.v1' const timelineLimit = 200 type ViewMode = 'page' | 'study' | 'notes' | 'compare' type StrongDisplayMode = 'off' | 'highlight' | 'numbers' type PageMotion = 'idle' | 'turn-forward' | 'turn-back' type TimelineEntryType = 'verse' | 'strong' | 'footnote' | 'search' | 'navigation' | 'view' | 'setting' type TimelineEntry = { id: string at: string type: TimelineEntryType label: string reference?: string detail?: string } type StudySession = { id: string title: string baseTranslation: string footnotesEnabled: boolean strongsEnabled: boolean commentaryEnabled: boolean dictionaryEnabled: boolean timelineEnabled: boolean createdAt: string updatedAt: string selectedId: number | null pageStartId: number | null viewMode: ViewMode strongDisplay: StrongDisplayMode noteDraft: string timeline: TimelineEntry[] } function reference(verse: Verse) { return `${verse.book} ${verse.chapter}:${verse.verse}` } function createSession(title = 'Study Session'): StudySession { const now = new Date().toISOString() const id = typeof crypto !== 'undefined' && 'randomUUID' in crypto ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}` return { id, title, baseTranslation: 'kjv-eng-kjv2006', footnotesEnabled: true, strongsEnabled: true, commentaryEnabled: true, dictionaryEnabled: true, timelineEnabled: true, createdAt: now, updatedAt: now, selectedId: null, pageStartId: null, viewMode: 'notes', strongDisplay: 'highlight', noteDraft: '', timeline: [], } } function timelineId() { return `${Date.now()}-${Math.random().toString(36).slice(2)}` } function formatTimelineTime(value: string) { return new Date(value).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) } function abbreviateBook(book: string) { const abbreviations: Record = { Genesis: 'Gen', Exodus: 'Ex', Leviticus: 'Lev', Numbers: 'Num', Deuteronomy: 'Deut', Joshua: 'Josh', Judges: 'Judg', Ruth: 'Ruth', '1 Samuel': '1 Sam', '2 Samuel': '2 Sam', '1 Kings': '1 Kgs', '2 Kings': '2 Kgs', '1 Chronicles': '1 Chr', '2 Chronicles': '2 Chr', Ezra: 'Ezra', Nehemiah: 'Neh', Esther: 'Est', Job: 'Job', Psalms: 'Ps', Proverbs: 'Prov', Ecclesiastes: 'Eccl', 'Song of Solomon': 'Song', Isaiah: 'Isa', Jeremiah: 'Jer', Lamentations: 'Lam', Ezekiel: 'Ezek', Daniel: 'Dan', Hosea: 'Hos', Joel: 'Joel', Amos: 'Amos', Obadiah: 'Ob', Jonah: 'Jon', Micah: 'Mic', Nahum: 'Nah', Habakkuk: 'Hab', Zephaniah: 'Zeph', Haggai: 'Hag', Zechariah: 'Zech', Malachi: 'Mal', Matthew: 'Matt', Mark: 'Mark', Luke: 'Luke', John: 'John', Acts: 'Acts', Romans: 'Rom', '1 Corinthians': '1 Cor', '2 Corinthians': '2 Cor', Galatians: 'Gal', Ephesians: 'Eph', Philippians: 'Phil', Colossians: 'Col', '1 Thessalonians': '1 Th', '2 Thessalonians': '2 Th', '1 Timothy': '1 Tim', '2 Timothy': '2 Tim', Titus: 'Titus', Philemon: 'Phlm', Hebrews: 'Heb', James: 'Jas', '1 Peter': '1 Pet', '2 Peter': '2 Pet', '1 John': '1 Jn', '2 John': '2 Jn', '3 John': '3 Jn', Jude: 'Jude', Revelation: 'Rev', } return abbreviations[book] ?? book.slice(0, 4) } function dictionaryTermsForVerse(verse: Verse) { const stopWords = new Set([ 'a', 'an', 'and', 'are', 'as', 'at', 'be', 'but', 'by', 'for', 'from', 'he', 'her', 'him', 'his', 'i', 'in', 'is', 'it', 'of', 'on', 'or', 'that', 'the', 'their', 'them', 'they', 'this', 'to', 'unto', 'was', 'were', 'with', 'ye', 'you', ]) const strongSurfaces = verse.strongs.map((entry) => entry.surface) const textWords = [...verse.text.matchAll(/[A-Za-z][A-Za-z'-]*/g)].map((match) => match[0]) const unique = new Map() for (const term of [...strongSurfaces, ...textWords]) { const normalized = term.toLowerCase().replace(/[^a-z0-9\s'-]/g, '').trim() if (!normalized || stopWords.has(normalized) || normalized.length < 3) continue unique.set(normalized, term) } return [...unique.values()].slice(0, 28) } function App() { const [railOpen, setRailOpen] = useState(false) const [sessionModalOpen, setSessionModalOpen] = useState(false) const [aboutModalOpen, setAboutModalOpen] = useState(false) const [timelineModalOpen, setTimelineModalOpen] = useState(false) const [settingsModalOpen, setSettingsModalOpen] = useState(false) const [notesDrawerOpen, setNotesDrawerOpen] = useState(false) const [pageMotion, setPageMotion] = useState('idle') 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 [sessions, setSessions] = useState([]) const [activeSessionId, setActiveSessionId] = useState('') const [newSessionTitle, setNewSessionTitle] = useState('New KJV Study') const [newSessionFootnotes, setNewSessionFootnotes] = useState(true) const [newSessionStrongs, setNewSessionStrongs] = useState(true) const [newSessionCommentary, setNewSessionCommentary] = useState(true) const [newSessionDictionary, setNewSessionDictionary] = useState(true) const [newSessionTimeline, setNewSessionTimeline] = useState(true) const [lemmaFontSize, setLemmaFontSize] = useState(17) const [linkedFontSize, setLinkedFontSize] = useState(15) const [originalCollapsed, setOriginalCollapsed] = useState(false) const [linkedCollapsed, setLinkedCollapsed] = useState(false) const [definitionCollapsed, setDefinitionCollapsed] = useState(false) const [commentaryCollapsed, setCommentaryCollapsed] = useState(false) const [dictionaryCollapsed, setDictionaryCollapsed] = useState(false) const [commentaryEntries, setCommentaryEntries] = useState([]) const [dictionaryEntries, setDictionaryEntries] = useState([]) const [marginResourcesLoading, setMarginResourcesLoading] = useState(false) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const restoredSessionId = useRef('') const pageMotionTimeout = useRef(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) } function patchActiveSession(patch: Partial) { if (!activeSessionId) return const updatedAt = new Date().toISOString() setSessions((current) => current.map((session) => session.id === activeSessionId ? { ...session, ...patch, updatedAt } : session, ), ) } function recordTimeline( entry: Omit, patch: Partial = {}, ) { if (!activeSessionId) return const at = new Date().toISOString() const nextEntry: TimelineEntry = { id: timelineId(), at, ...entry } setSessions((current) => current.map((session) => session.id === activeSessionId ? { ...session, selectedId, pageStartId, viewMode, strongDisplay, noteDraft, ...patch, updatedAt: at, timeline: session.timelineEnabled === false ? session.timeline : [nextEntry, ...session.timeline].slice(0, timelineLimit), } : session, ), ) } function playPageTurnSound() { if (!window.AudioContext) return try { const audio = new window.AudioContext() const oscillator = audio.createOscillator() const gain = audio.createGain() oscillator.type = 'triangle' oscillator.frequency.setValueAtTime(120, audio.currentTime) oscillator.frequency.exponentialRampToValueAtTime(42, audio.currentTime + 0.08) gain.gain.setValueAtTime(0.0001, audio.currentTime) gain.gain.exponentialRampToValueAtTime(0.06, audio.currentTime + 0.012) gain.gain.exponentialRampToValueAtTime(0.0001, audio.currentTime + 0.11) oscillator.connect(gain) gain.connect(audio.destination) oscillator.start() oscillator.stop(audio.currentTime + 0.12) window.setTimeout(() => audio.close().catch(() => null), 180) } catch { // Page sound is intentionally best-effort. } } function triggerPageMotion(direction: PageMotion) { if (pageMotionTimeout.current) window.clearTimeout(pageMotionTimeout.current) setPageMotion('idle') window.requestAnimationFrame(() => { setPageMotion(direction) playPageTurnSound() pageMotionTimeout.current = window.setTimeout(() => setPageMotion('idle'), 520) }) } 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) recordTimeline( { type: 'search', label: trimmed ? `Search: ${trimmed}` : 'Cleared search', detail: `${results.length} matches`, }, { selectedId: firstResult?.id ?? null, pageStartId: 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(() => { const savedStudySettings = localStorage.getItem(studySettingsStorageKey) const savedSessions = localStorage.getItem(sessionStorageKey) const savedActiveSessionId = localStorage.getItem(activeSessionStorageKey) let nextSessions: StudySession[] = [] if (savedStudySettings) { try { const parsed = JSON.parse(savedStudySettings) as Partial<{ lemmaFontSize: number linkedFontSize: number originalCollapsed: boolean linkedCollapsed: boolean definitionCollapsed: boolean }> setLemmaFontSize(parsed.lemmaFontSize ?? 17) setLinkedFontSize(parsed.linkedFontSize ?? 15) setOriginalCollapsed(parsed.originalCollapsed ?? false) setLinkedCollapsed(parsed.linkedCollapsed ?? false) setDefinitionCollapsed(parsed.definitionCollapsed ?? false) } catch { localStorage.removeItem(studySettingsStorageKey) } } if (savedSessions) { try { const parsed = JSON.parse(savedSessions) as StudySession[] nextSessions = Array.isArray(parsed) ? parsed : [] } catch { nextSessions = [] } } if (!nextSessions.length) { nextSessions = [createSession('KJV Study Session')] } nextSessions = nextSessions.map((session) => ({ ...createSession(session.title), ...session, baseTranslation: session.baseTranslation ?? 'kjv-eng-kjv2006', footnotesEnabled: session.footnotesEnabled ?? true, strongsEnabled: session.strongsEnabled ?? true, commentaryEnabled: session.commentaryEnabled ?? true, dictionaryEnabled: session.dictionaryEnabled ?? true, timelineEnabled: session.timelineEnabled ?? true, })) setSessions(nextSessions) setActiveSessionId( nextSessions.some((session) => session.id === savedActiveSessionId) ? savedActiveSessionId ?? nextSessions[0].id : nextSessions[0].id, ) loadLibrary() .catch((reason) => setError(String(reason))) .finally(() => setLoading(false)) }, []) useEffect(() => { if (sessions.length) { localStorage.setItem(sessionStorageKey, JSON.stringify(sessions)) } }, [sessions]) useEffect(() => { if (activeSessionId) { localStorage.setItem(activeSessionStorageKey, activeSessionId) } }, [activeSessionId]) useEffect(() => { localStorage.setItem( studySettingsStorageKey, JSON.stringify({ lemmaFontSize, linkedFontSize, originalCollapsed, linkedCollapsed, definitionCollapsed, }), ) }, [definitionCollapsed, lemmaFontSize, linkedCollapsed, linkedFontSize, originalCollapsed]) useEffect(() => { if (restoredSessionId.current === activeSessionId) return const activeSession = sessions.find((session) => session.id === activeSessionId) if (!activeSession) return restoredSessionId.current = activeSessionId setViewMode(activeSession.viewMode) setStrongDisplay(activeSession.strongDisplay) setNoteDraft(activeSession.noteDraft) setSelectedId((current) => activeSession.selectedId ?? current) setPageStartId((current) => activeSession.pageStartId ?? current) setSelectedStrong(null) }, [activeSessionId, sessions]) 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 activeSession = useMemo( () => sessions.find((session) => session.id === activeSessionId) ?? sessions[0], [activeSessionId, sessions], ) useEffect(() => { let cancelled = false async function loadMarginResources() { if (!selectedVerse) { setCommentaryEntries([]) setDictionaryEntries([]) return } setMarginResourcesLoading(true) try { const [nextCommentary, nextDictionary] = await Promise.all([ activeSession?.commentaryEnabled === false ? Promise.resolve([]) : getCommentaryForVerse(selectedVerse), activeSession?.dictionaryEnabled === false ? Promise.resolve([]) : getDictionaryEntriesForTerms(dictionaryTermsForVerse(selectedVerse)), ]) if (cancelled) return setCommentaryEntries(nextCommentary) setDictionaryEntries(nextDictionary) } catch (reason) { if (!cancelled) setError(String(reason)) } finally { if (!cancelled) setMarginResourcesLoading(false) } } loadMarginResources() return () => { cancelled = true } }, [activeSession?.commentaryEnabled, activeSession?.dictionaryEnabled, 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 visibleBookTabs = useMemo(() => { if (!selectedVerse || !books.length) return [] const visibleCount = 24 const selectedIndex = Math.max(0, books.indexOf(selectedVerse.book)) const halfWindow = Math.floor(visibleCount / 2) const maxStart = Math.max(0, books.length - visibleCount) const start = Math.min(Math.max(0, selectedIndex - halfWindow), maxStart) return books.slice(start, start + visibleCount) }, [books, selectedVerse]) 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) recordTimeline({ type: 'strong', label: `Strong's ${strong}`, reference: selectedVerse ? reference(selectedVerse) : undefined, detail: 'Opened matching verses search', }) 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 const currentBookIndex = selectedVerse ? books.indexOf(selectedVerse.book) : -1 const nextBookIndex = books.indexOf(next.book) triggerPageMotion(nextBookIndex >= currentBookIndex ? 'turn-forward' : 'turn-back') setQuery('') setSelectedStrong(null) setVerses(libraryVerses) setSelectedId(next.id) const nextPageStartId = findPageStartId(next) setPageStartId(nextPageStartId) recordTimeline( { type: 'navigation', label: reference(next), reference: reference(next), detail: 'Jumped from selector', }, { selectedId: next.id, pageStartId: nextPageStartId }, ) } 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 triggerPageMotion(direction > 0 ? 'turn-forward' : 'turn-back') setPageStartId(next.id) setSelectedId(next.id) setSelectedStrong(null) recordTimeline( { type: 'navigation', label: direction > 0 ? 'Next page' : 'Previous page', reference: reference(next), }, { selectedId: next.id, pageStartId: next.id }, ) } 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) recordTimeline( { type: 'verse', label: reference(verse), reference: reference(verse), }, { selectedId: verse.id }, ) } function selectStrong(verse: Verse, strong: string) { setSelectedId(verse.id) setSelectedStrong(strong) recordTimeline( { type: 'strong', label: strong, reference: reference(verse), }, { selectedId: verse.id }, ) } function setStrongDisplayMode(nextStrongDisplay: StrongDisplayMode) { setStrongDisplay(nextStrongDisplay) recordTimeline( { type: 'setting', label: `Strong's display: ${nextStrongDisplay}`, }, { strongDisplay: nextStrongDisplay }, ) } function updateNoteDraft(value: string) { setNoteDraft(value) patchActiveSession({ noteDraft: value }) } function appendNoteClip(value: string) { const clip = value.trim() if (!clip) return const nextValue = noteDraft.trim() ? `${noteDraft.trim()}\n\n${clip}` : clip updateNoteDraft(nextValue) } function startTextDrag(event: DragEvent, value: string) { event.dataTransfer.setData('text/plain', value) event.dataTransfer.effectAllowed = 'copy' setNotesDrawerOpen(true) } function renameActiveSession(title: string) { patchActiveSession({ title }) } function startNewSession() { const nextSession = { ...createSession(newSessionTitle.trim() || `Study Session ${sessions.length + 1}`), footnotesEnabled: newSessionFootnotes, strongsEnabled: newSessionStrongs, commentaryEnabled: newSessionCommentary, dictionaryEnabled: newSessionDictionary, timelineEnabled: newSessionTimeline, } setSessions((current) => [nextSession, ...current]) setActiveSessionId(nextSession.id) setSessionModalOpen(false) } function setActiveSessionOption(patch: Partial) { patchActiveSession(patch) if (patch.strongsEnabled === false) { setStrongDisplay('off') } if ( typeof patch.strongsEnabled === 'boolean' || typeof patch.footnotesEnabled === 'boolean' || typeof patch.commentaryEnabled === 'boolean' || typeof patch.dictionaryEnabled === 'boolean' ) { recordTimeline({ type: 'setting', label: 'Updated session resources' }, patch) } } function exportActiveSession() { if (!activeSession) return const blob = new Blob([JSON.stringify(activeSession, null, 2)], { type: 'application/json', }) const url = URL.createObjectURL(blob) const link = document.createElement('a') link.href = url link.download = `${activeSession.title.toLowerCase().replace(/[^a-z0-9]+/g, '-') || 'study-session'}.json` link.click() URL.revokeObjectURL(url) } function restoreTimelineEntry(entry: TimelineEntry) { if (!entry.reference) return const [bookPart, rest] = entry.reference.split(/ (?=\d+:\d+$)/) const [chapter, verse] = rest?.split(':').map(Number) ?? [] if (!bookPart || !chapter || !verse) return const next = libraryVerses.find( (entryVerse) => entryVerse.book === bookPart && entryVerse.chapter === chapter && entryVerse.verse === verse, ) if (!next) return triggerPageMotion(next.id >= (selectedVerse?.id ?? next.id) ? 'turn-forward' : 'turn-back') setQuery('') setSelectedStrong(null) setVerses(libraryVerses) setSelectedId(next.id) if (!pageVerses.some((pageVerse) => pageVerse.id === next.id)) { setPageStartId(findPageStartId(next)) } } function recordFootnote(referenceText: string, text: string) { recordTimeline({ type: 'footnote', label: `Footnote ${referenceText}`, reference: selectedVerse ? reference(selectedVerse) : undefined, detail: text, }) } function renderVerseText(verse: Verse) { if (!activeSession?.strongsEnabled || !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 const isHebrew = selectedVerse.strongs.some((entry) => entry.strong.startsWith('H')) const direction = isHebrew ? 'rtl' : 'ltr' return (
setOriginalCollapsed((current) => !current)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() setOriginalCollapsed((current) => !current) } }} >
{!originalCollapsed ? (

{selectedVerse.strongs.map((entry) => ( selectStrong(selectedVerse, entry.strong)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() selectStrong(selectedVerse, entry.strong) } }} > {entry.lemma || entry.surface} ))}

) : null}
) } function renderLinkedEnglishLine() { if (!selectedVerse?.strongs.length) return null return (
setLinkedCollapsed((current) => !current)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() setLinkedCollapsed((current) => !current) } }} >
{!linkedCollapsed ? (

{selectedVerse.strongs.map((entry) => ( selectStrong(selectedVerse, entry.strong)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() selectStrong(selectedVerse, entry.strong) } }} > {entry.surface} ))}

) : null}
) } function renderCommentarySection() { if (activeSession?.commentaryEnabled === false) { return (
Off

Commentary is disabled for this session.

) } return (
setCommentaryCollapsed((current) => !current)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() setCommentaryCollapsed((current) => !current) } }} > {commentaryEntries.length ? 'MHCC' : marginResourcesLoading ? 'Loading' : 'None'}
{!commentaryCollapsed ? ( commentaryEntries.length ? (
{commentaryEntries.map((entry) => (
startTextDrag(event, `${entry.reference}\n${entry.text}\n- ${entry.source}`) } >

{entry.reference}

{entry.text}

{entry.source}
))}
) : (

{marginResourcesLoading ? 'Loading commentary...' : 'No commentary matched this verse.'}

) ) : null}
) } function renderDictionarySection() { if (activeSession?.dictionaryEnabled === false) { return (
Off

Dictionary lookup is disabled for this session.

) } return (
setDictionaryCollapsed((current) => !current)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() setDictionaryCollapsed((current) => !current) } }} > {dictionaryEntries.length ? `${dictionaryEntries.length} terms` : marginResourcesLoading ? 'Loading' : 'None'}
{!dictionaryCollapsed ? ( dictionaryEntries.length ? (
{dictionaryEntries.slice(0, 6).map((entry) => (
startTextDrag( event, `${entry.term}\n${entry.definitions .map((definition) => `${definition.sourceName}: ${definition.text}`) .join('\n\n')}`, ) } >

{entry.term}

{entry.definitions.slice(0, 2).map((definition) => (

{definition.sourceName} {definition.text}

))}
))}
) : (

{marginResourcesLoading ? 'Loading dictionary...' : 'No dictionary terms matched this verse.'}

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

startTextDrag(event, `${reference(verse)} ${verse.text}`)} onClick={() => selectVerse(verse)} > {verse.verse} {renderVerseText(verse)}

))}
) } function renderPageTurner() { return ( ) } function renderStudyMargin() { if (!selectedVerse) return null return ( ) } function renderFootnoteStrip() { if (activeSession?.footnotesEnabled === false) { return (
Footnotes Footnotes are disabled for this session.
) } const visibleFootnotes = selectedFootnotes.length ? selectedFootnotes : pageFootnotes.slice(0, 4) return (
Footnotes {visibleFootnotes.length ? (
{visibleFootnotes.map((note) => (

recordFootnote(note.reference, note.text)} draggable onDragStart={(event) => startTextDrag(event, `${note.reference} ${note.text}`)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() recordFootnote(note.reference, note.text) } }} > {note.reference} {note.text}

))}
) : ( No footnotes for the selected verse. )}
) } function renderBookTabs() { if (!selectedVerse) return null return ( ) } function renderTimelineModal() { if (!timelineModalOpen) return null return (
setTimelineModalOpen(false)}>
event.stopPropagation()} >
Timeline

{activeSession?.title ?? 'Study Session'}

{activeSession?.timeline.length ? (
    {[...activeSession.timeline].reverse().map((entry) => (
  1. ))}
) : (
)}
) } function renderSessionModal() { if (!sessionModalOpen) return null return (
setSessionModalOpen(false)}>
event.stopPropagation()} >
Session

Manage Study Session

Current Session

Translation
KJV
Current page
{pageRange}
Timeline
{activeSession?.timeline.length ?? 0} events
Started
{activeSession ? new Date(activeSession.createdAt).toLocaleString() : 'Not started'}

New Session

) } function renderAboutModal() { if (!aboutModalOpen) return null return (
setAboutModalOpen(false)}>
event.stopPropagation()} >
About

Libre Study

Libre Study is a free Bible study application built by Christ IT. The project started in July 2026 as an open study tool focused on readable Bible pages, linked study data, notes, and reusable public Bible packages.

Project
Christ IT / Libre Study
Current Bible package
King James Version, public domain KJV text package with Strong's-linked study data.
Software stack
Tauri, TypeScript, React, SQLite, Rust, Vite.
Source data catalog
LibreBible packages are maintained separately so this app and future sites can consume the same resources.
) } function renderSettingsModal() { if (!settingsModalOpen) return null return (
setSettingsModalOpen(false)}>
event.stopPropagation()} >
Settings

Study Display

Original Language Line

English Strong's Line

Definition Panel

) } function renderNotesDrawer() { if (!selectedVerse) return null return (