import { useEffect, useMemo, useRef, useState } from 'react' import { addTagToVerse, getCommentaryForVerse, getDictionaryEntriesForTerms, getLibraryState, getTranslationNotesForVerses, getTranslations, searchVerses, } from './studyClient' import { createSession, defaultTranslationId, hostedWorkspaceEnabled, loadWorkspaceSnapshot, saveWorkspaceSnapshot, timelineId, timelineLimit, } from './workspace' import type { DragEvent, MouseEvent as ReactMouseEvent, ReactNode } from 'react' import type { CommentaryEntry, DictionaryEntry, TranslationResource, Verse } from './types' import type { StrongDisplayMode, StudySession, StudySettings, ThemeMode, TimelineEntry, ViewMode, } from './workspace' import './App.css' const tagColors = ['#2f6f5e', '#735c0f', '#8a3ffc', '#b42318', '#245ba7'] const pageSize = 34 type PageMotion = 'idle' | 'turn-forward' | 'turn-back' function reference(verse: Verse) { return `${verse.book} ${verse.chapter}:${verse.verse}` } function replaceChangedVerses(verses: Verse[], hydrate: (verse: Verse) => Verse) { let changed = false const next = verses.map((verse) => { const hydrated = hydrate(verse) if (hydrated !== verse) changed = true return hydrated }) return changed ? next : verses } 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 [notesPinned, setNotesPinned] = useState(false) const [pageMotion, setPageMotion] = useState('idle') const [viewMode, setViewMode] = useState('notes') const [query, setQuery] = useState('') const [themeMode, setThemeMode] = useState('light') const [libraryVerses, setLibraryVerses] = useState([]) const [verses, setVerses] = useState([]) const [translations, setTranslations] = 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 [newSessionBaseTranslation, setNewSessionBaseTranslation] = useState(defaultTranslationId) 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 [studyNotesCollapsed, setStudyNotesCollapsed] = useState(false) const [commentaryCollapsed, setCommentaryCollapsed] = useState(false) const [dictionaryCollapsed, setDictionaryCollapsed] = useState(false) const [showOriginalLine, setShowOriginalLine] = useState(true) const [showLinkedLine, setShowLinkedLine] = useState(true) const [showDefinitionPanel, setShowDefinitionPanel] = useState(true) const [showCommentaryPanel, setShowCommentaryPanel] = useState(true) const [showDictionaryPanel, setShowDictionaryPanel] = useState(true) const [splitPercent, setSplitPercent] = useState(50) 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) const notesDragOpenTimeout = useRef(null) const remoteWorkspaceSaveTimeout = useRef(null) const readerRef = useRef(null) const hydratedNoteChapters = useRef(new Set()) const workspaceHydrated = useRef(false) function applyStudySettings(settings?: Partial) { if (!settings) return setLemmaFontSize(settings.lemmaFontSize ?? 17) setLinkedFontSize(settings.linkedFontSize ?? 15) setOriginalCollapsed(settings.originalCollapsed ?? false) setLinkedCollapsed(settings.linkedCollapsed ?? false) setDefinitionCollapsed(settings.definitionCollapsed ?? false) setStudyNotesCollapsed(settings.studyNotesCollapsed ?? false) setCommentaryCollapsed(settings.commentaryCollapsed ?? false) setDictionaryCollapsed(settings.dictionaryCollapsed ?? false) setShowOriginalLine(settings.showOriginalLine ?? true) setShowLinkedLine(settings.showLinkedLine ?? true) setShowDefinitionPanel(settings.showDefinitionPanel ?? true) setShowCommentaryPanel(settings.showCommentaryPanel ?? true) setShowDictionaryPanel(settings.showDictionaryPanel ?? true) setSplitPercent(settings.splitPercent ?? 50) setNotesPinned(settings.notesPinned ?? false) setThemeMode(settings.themeMode ?? 'light') } async function loadLibrary(translationId = activeSession?.baseTranslation ?? defaultTranslationId) { setError(null) const state = await getLibraryState(translationId) 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, activeSession?.baseTranslation ?? defaultTranslationId) 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, activeSession?.baseTranslation ?? defaultTranslationId, ) setLibraryVerses((current) => current.map((verse) => (verse.id === updated.id ? updated : verse)), ) setVerses((current) => current.map((verse) => (verse.id === updated.id ? updated : verse)), ) setTagInput('') } const studySettingsSnapshot = useMemo( () => ({ lemmaFontSize, linkedFontSize, originalCollapsed, linkedCollapsed, definitionCollapsed, studyNotesCollapsed, commentaryCollapsed, dictionaryCollapsed, showOriginalLine, showLinkedLine, showDefinitionPanel, showCommentaryPanel, showDictionaryPanel, splitPercent, notesPinned, themeMode, }), [ commentaryCollapsed, definitionCollapsed, dictionaryCollapsed, lemmaFontSize, linkedCollapsed, linkedFontSize, notesPinned, originalCollapsed, showCommentaryPanel, showDefinitionPanel, showDictionaryPanel, showLinkedLine, showOriginalLine, splitPercent, studyNotesCollapsed, themeMode, ], ) useEffect(() => { let cancelled = false async function restoreWorkspace() { const workspace = await loadWorkspaceSnapshot() const selectedSession = workspace.sessions.find((session) => session.id === workspace.activeSessionId) ?? workspace.sessions[0] if (cancelled) return applyStudySettings(workspace.studySettings) setSessions(workspace.sessions) setActiveSessionId(workspace.activeSessionId) getTranslations().then(setTranslations).catch((reason) => setError(String(reason))) loadLibrary(selectedSession.baseTranslation) .catch((reason) => setError(String(reason))) .finally(() => { if (!cancelled) { setLoading(false) window.setTimeout(() => { workspaceHydrated.current = true }, 0) } }) } restoreWorkspace().catch((reason) => { if (!cancelled) { setError(String(reason)) setLoading(false) } }) return () => { cancelled = true } }, []) useEffect(() => { if (!workspaceHydrated.current || !sessions.length || !activeSessionId) { return } if (remoteWorkspaceSaveTimeout.current) { window.clearTimeout(remoteWorkspaceSaveTimeout.current) } remoteWorkspaceSaveTimeout.current = window.setTimeout(() => { saveWorkspaceSnapshot({ sessions, activeSessionId, studySettings: studySettingsSnapshot, }).catch((reason) => { console.warn( hostedWorkspaceEnabled() ? 'Unable to save hosted workspace.' : 'Unable to save local workspace.', reason, ) }) remoteWorkspaceSaveTimeout.current = null }, 650) return () => { if (remoteWorkspaceSaveTimeout.current) { window.clearTimeout(remoteWorkspaceSaveTimeout.current) remoteWorkspaceSaveTimeout.current = null } } }, [ activeSessionId, sessions, studySettingsSnapshot, ]) 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) loadLibrary(activeSession.baseTranslation).catch((reason) => setError(String(reason))) 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], ) const activeTranslation = useMemo( () => translations.find((translation) => translation.id === activeSession?.baseTranslation) ?? translations.find((translation) => translation.id === defaultTranslationId), [activeSession?.baseTranslation, translations], ) 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]) useEffect(() => { if (!activeSession?.baseTranslation || pageVerses.length === 0) return const chapterKeys = [ ...new Set( pageVerses.map( (verse) => `${activeSession.baseTranslation}:${verse.book}:${verse.chapter}`, ), ), ] const unloadedChapterKeys = chapterKeys.filter( (chapterKey) => !hydratedNoteChapters.current.has(chapterKey), ) if (unloadedChapterKeys.length === 0) return for (const chapterKey of unloadedChapterKeys) hydratedNoteChapters.current.add(chapterKey) let cancelled = false getTranslationNotesForVerses(activeSession.baseTranslation, pageVerses) .then((notesByVerse) => { if (cancelled || notesByVerse.size === 0) return const hydrate = (verse: Verse) => { const notes = notesByVerse.get(String(verse.id)) if (!notes) return verse if ( (verse.footnotes?.length ?? 0) === notes.footnotes.length && (verse.studyNotes?.length ?? 0) === notes.studyNotes.length ) { return verse } return { ...verse, footnotes: notes.footnotes, studyNotes: notes.studyNotes } } setLibraryVerses((current) => replaceChangedVerses(current, hydrate)) setVerses((current) => replaceChangedVerses(current, hydrate)) }) .catch((reason) => setError(String(reason))) return () => { cancelled = true } }, [activeSession?.baseTranslation, pageVerses]) const pageRange = pageVerses.length ? `${reference(pageVerses[0])} - ${reference(pageVerses[pageVerses.length - 1])}` : 'No page loaded' const selectedFootnotes = selectedVerse?.footnotes ?? [] const selectedStudyNotes = selectedVerse?.studyNotes ?? [] 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 }, ) } async function changeTranslation(translationId: string) { const translation = translations.find((item) => item.id === translationId) ?? translations.find((item) => item.id === defaultTranslationId) const state = await getLibraryState(translationId) const firstVerse = state.verses[0] ?? null setQuery('') setSelectedStrong(null) setLibraryVerses(state.verses) setVerses(state.verses) setSelectedId(firstVerse?.id ?? null) setPageStartId(firstVerse?.id ?? null) patchActiveSession({ baseTranslation: translationId, selectedId: firstVerse?.id ?? null, pageStartId: firstVerse?.id ?? null, }) recordTimeline( { type: 'setting', label: `Translation: ${translation?.abbreviation ?? translationId}`, detail: translation?.title, }, { baseTranslation: translationId, selectedId: firstVerse?.id ?? null, pageStartId: firstVerse?.id ?? null, }, ) } 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 recordDisplaySetting(label: string) { recordTimeline({ type: 'setting', label, }) } function changeThemeMode(nextThemeMode: ThemeMode) { setThemeMode(nextThemeMode) recordDisplaySetting(`Theme: ${nextThemeMode}`) } function beginSplitResize(event: ReactMouseEvent) { event.preventDefault() const reader = readerRef.current if (!reader) return const resize = (moveEvent: MouseEvent) => { const rect = reader.getBoundingClientRect() const next = ((moveEvent.clientX - rect.left) / rect.width) * 100 setSplitPercent(Math.min(68, Math.max(36, next))) } const stop = () => { window.removeEventListener('mousemove', resize) window.removeEventListener('mouseup', stop) recordDisplaySetting('Adjusted Bible and study margin split') } window.addEventListener('mousemove', resize) window.addEventListener('mouseup', stop, { once: true }) } 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' } function scheduleNotesDragOpen() { if (notesPinned || notesDragOpenTimeout.current) return notesDragOpenTimeout.current = window.setTimeout(() => { setNotesDrawerOpen(true) notesDragOpenTimeout.current = null }, 550) } function clearNotesDragOpen() { if (!notesDragOpenTimeout.current) return window.clearTimeout(notesDragOpenTimeout.current) notesDragOpenTimeout.current = null } function renameActiveSession(title: string) { patchActiveSession({ title }) } function startNewSession() { const nextSession = { ...createSession( newSessionTitle.trim() || `Study Session ${sessions.length + 1}`, newSessionBaseTranslation, ), footnotesEnabled: newSessionFootnotes, strongsEnabled: newSessionStrongs, commentaryEnabled: newSessionCommentary, dictionaryEnabled: newSessionDictionary, timelineEnabled: newSessionTimeline, } setSessions((current) => [nextSession, ...current]) setActiveSessionId(nextSession.id) loadLibrary(nextSession.baseTranslation).catch((reason) => setError(String(reason))) 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 (!showOriginalLine || !selectedVerse?.strongs.length) return null const isHebrew = selectedVerse.strongs.some((entry) => entry.strong.startsWith('H')) const direction = isHebrew ? 'rtl' : 'ltr' return (
{ setOriginalCollapsed((current) => !current) recordDisplaySetting('Toggled original language accordion') }} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() setOriginalCollapsed((current) => !current) recordDisplaySetting('Toggled original language accordion') } }} >
{!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 (!showLinkedLine || !selectedVerse?.strongs.length) return null return (
{ setLinkedCollapsed((current) => !current) recordDisplaySetting('Toggled English linked text accordion') }} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() setLinkedCollapsed((current) => !current) recordDisplaySetting('Toggled English linked text accordion') } }} >
{!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 renderStudyNotesSection() { if (activeSession?.footnotesEnabled === false) return null return (
{ setStudyNotesCollapsed((current) => !current) recordDisplaySetting('Toggled translation study notes accordion') }} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() setStudyNotesCollapsed((current) => !current) recordDisplaySetting('Toggled translation study notes accordion') } }} > {selectedStudyNotes.length ? `${selectedStudyNotes.length} notes` : 'None'}
{!studyNotesCollapsed ? ( selectedStudyNotes.length ? (
{selectedStudyNotes.map((note) => (

startTextDrag(event, `${note.reference} ${note.text}`)} > {note.reference} {note.text}

))}
) : (

No translation study notes for the selected verse.

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

Commentary is disabled for this session.

) } return (
{ setCommentaryCollapsed((current) => !current) recordDisplaySetting('Toggled commentary accordion') }} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() setCommentaryCollapsed((current) => !current) recordDisplaySetting('Toggled commentary accordion') } }} > {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 (!showDictionaryPanel) return null if (activeSession?.dictionaryEnabled === false) { return (
Off

Dictionary lookup is disabled for this session.

) } return (
{ setDictionaryCollapsed((current) => !current) recordDisplaySetting('Toggled dictionary accordion') }} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() setDictionaryCollapsed((current) => !current) recordDisplaySetting('Toggled dictionary accordion') } }} > {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 = activeTranslation?.abbreviation ?? 'Bible') { 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
{activeTranslation?.abbreviation ?? activeSession?.baseTranslation ?? 'n/a'}
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
{activeTranslation ? `${activeTranslation.title}. ${activeTranslation.licenseName}` : 'LibreBible translation package loaded from the local catalog.'}
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

Theme

Original Language Line

English Strong's Line

Definition Panel

Reference Panels

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