import { useEffect, useMemo, useRef, useState } from 'react' import { addTagToVerse, getCommentaryForVerse, getAudioContextForVerse, getAudioObjectUrl, getDictionaryEntriesForTerms, getLibraryState, getMapContextForVerses, getTranslationNotesForVerses, getTranslations, searchVerses, } from './studyClient' import { createSession, defaultTranslationId, getAuthClient, loadHostedSession, loadWorkspaceSnapshot, loginHostedWorkspace, logoutHostedWorkspace, saveWorkspaceSnapshot, timelineId, timelineLimit, } from './workspace' import type { DragEvent, FormEvent, MouseEvent as ReactMouseEvent, ReactNode } from 'react' import type { AudioBibleContext, AudioBibleSegment, CommentaryEntry, DictionaryEntry, MapContext, TranslationResource, Verse, } from './types' import type { StrongDisplayMode, HostedUser, 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 [mapContext, setMapContext] = useState({ selectedPlaces: [], pagePlaces: [], relatedRoutes: [], }) const [audioContext, setAudioContext] = useState(null) const [audioResourcesLoading, setAudioResourcesLoading] = useState(false) const [audioStatus, setAudioStatus] = useState('Ready') const [audioPlayingSegmentId, setAudioPlayingSegmentId] = useState(null) const [audioVolume, setAudioVolume] = useState(0.85) const [audioSpeed, setAudioSpeed] = useState(1) const [marginResourcesLoading, setMarginResourcesLoading] = useState(false) const [mapResourcesLoading, setMapResourcesLoading] = useState(false) const [authRequired, setAuthRequired] = useState(false) const [hostedUser, setHostedUser] = useState(null) const [loginUsername, setLoginUsername] = useState('') const [loginPassword, setLoginPassword] = useState('') const [loginBusy, setLoginBusy] = useState(false) const [loginError, setLoginError] = useState(null) 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 audioRef = useRef(null) const audioQueueRef = useRef([]) const audioQueueIndexRef = useRef(0) 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, ], ) async function restoreWorkspace(cancelled = () => false) { setLoading(true) setError(null) setAuthRequired(false) workspaceHydrated.current = false if (getAuthClient().enabled) { const session = await loadHostedSession() if (cancelled()) return setHostedUser(session.user) if (!session.authenticated) { setAuthRequired(true) setLoading(false) return } } 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))) await loadLibrary(selectedSession.baseTranslation) if (cancelled()) return setLoading(false) window.setTimeout(() => { workspaceHydrated.current = true }, 0) } useEffect(() => { let cancelled = false restoreWorkspace(() => cancelled).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( getAuthClient().enabled ? '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(() => { let cancelled = false async function loadMapContext() { if (!selectedVerse || pageVerses.length === 0) { setMapContext({ selectedPlaces: [], pagePlaces: [], relatedRoutes: [] }) return } setMapResourcesLoading(true) try { const nextContext = await getMapContextForVerses(selectedVerse, pageVerses) if (!cancelled) setMapContext(nextContext) } catch (reason) { if (!cancelled) setError(String(reason)) } finally { if (!cancelled) setMapResourcesLoading(false) } } loadMapContext() return () => { cancelled = true } }, [pageVerses, selectedVerse]) useEffect(() => { let cancelled = false async function loadAudioContext() { if (!selectedVerse) { setAudioContext(null) return } setAudioResourcesLoading(true) try { const nextContext = await getAudioContextForVerse( activeSession?.baseTranslation ?? defaultTranslationId, selectedVerse, ) if (!cancelled) setAudioContext(nextContext) } catch (reason) { if (!cancelled) { setAudioContext(null) setError(String(reason)) } } finally { if (!cancelled) setAudioResourcesLoading(false) } } loadAudioContext() return () => { cancelled = true } }, [activeSession?.baseTranslation, selectedVerse]) useEffect(() => { if (!audioRef.current) return audioRef.current.volume = audioVolume audioRef.current.playbackRate = audioSpeed }, [audioSpeed, audioVolume]) 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) } async function submitHostedLogin(event: FormEvent) { event.preventDefault() setLoginBusy(true) setLoginError(null) try { const session = await loginHostedWorkspace(loginUsername.trim(), loginPassword) setHostedUser(session.user) setLoginPassword('') await restoreWorkspace() } catch (reason) { setLoginError(String(reason)) } finally { setLoginBusy(false) } } async function signOutHostedWorkspace() { setError(null) await logoutHostedWorkspace() setHostedUser(null) setSessions([]) setActiveSessionId('') setAuthRequired(true) setLoading(false) workspaceHydrated.current = false } 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, }) } async function playAudioQueue(segments: AudioBibleSegment[], startIndex = 0) { if (!segments.length) return audioQueueRef.current = segments audioQueueIndexRef.current = startIndex await playQueuedAudioSegment(startIndex) } async function playQueuedAudioSegment(index: number) { const segment = audioQueueRef.current[index] const audio = audioRef.current if (!segment || !audio) return setAudioResourcesLoading(true) setAudioStatus(`Loading ${segment.reference}`) try { const url = await getAudioObjectUrl(segment) audio.pause() audio.src = url audio.volume = audioVolume audio.playbackRate = audioSpeed await audio.play() setAudioPlayingSegmentId(segment.id) setAudioStatus(`Playing ${segment.reference}`) recordTimeline({ type: 'setting', label: `Audio: ${segment.reference}`, reference: segment.reference, detail: segment.audioZipFile, }) } catch (reason) { setAudioPlayingSegmentId(null) setAudioStatus(`Audio unavailable: ${String(reason)}`) } finally { setAudioResourcesLoading(false) } } function stopAudio() { const audio = audioRef.current audioQueueRef.current = [] audioQueueIndexRef.current = 0 if (audio) { audio.pause() audio.currentTime = 0 } setAudioPlayingSegmentId(null) setAudioStatus('Stopped') } function handleAudioEnded() { const nextIndex = audioQueueIndexRef.current + 1 if (nextIndex < audioQueueRef.current.length) { audioQueueIndexRef.current = nextIndex playQueuedAudioSegment(nextIndex).catch((reason) => setAudioStatus(String(reason))) return } audioQueueRef.current = [] audioQueueIndexRef.current = 0 setAudioPlayingSegmentId(null) setAudioStatus('Ready') } 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 renderMapSection() { const places = mapContext.pagePlaces const selectedPlaces = mapContext.selectedPlaces const routes = mapContext.relatedRoutes const bounds = mapBounds(places) return (
{places.length ? `${places.length} places` : mapResourcesLoading ? 'Loading' : 'None'}
{places.length ? ( <>
{places.slice(0, 8).map((place) => (

{place.placeName}

{place.modernLocationName || 'Unresolved location'} {place.confidenceLabel} confidence

))}
{routes.length ? (
Related routes {routes.map((route) => ( {route.title} {route.mapNumber} ))}
) : null}

Places: OpenBible.info. Routes: UBS Project MARBLE. Coordinates are contextual and may be approximate.

) : (

{mapResourcesLoading ? 'Loading contextual map data...' : 'No mapped places matched this page.'}

)}
) } function renderAudioSection() { const selectedSegment = audioContext?.selectedSegment ?? null const chapterSegments = audioContext?.chapterPlaylist?.segments ?? [] const selectedIndex = selectedSegment ? Math.max(0, chapterSegments.findIndex((segment) => segment.id === selectedSegment.id)) : 0 return (
{audioResourcesLoading ? 'Loading' : audioContext ? audioContext.shortTitle : 'None'}
{audioContext && selectedSegment ? ( <>

{audioPlayingSegmentId ? 'Playing' : 'Status'} {audioStatus}

{audioContext.attribution}

) } function mapBounds(places: MapContext['pagePlaces']) { const points = places.filter((place) => place.lon != null && place.lat != null) if (points.length === 0) { return { minLon: 0, maxLon: 1, minLat: 0, maxLat: 1 } } const minLon = Math.min(...points.map((place) => place.lon ?? 0)) const maxLon = Math.max(...points.map((place) => place.lon ?? 0)) const minLat = Math.min(...points.map((place) => place.lat ?? 0)) const maxLat = Math.max(...points.map((place) => place.lat ?? 0)) const lonPad = Math.max(0.2, (maxLon - minLon) * 0.18) const latPad = Math.max(0.2, (maxLat - minLat) * 0.18) return { minLon: minLon - lonPad, maxLon: maxLon + lonPad, minLat: minLat - latPad, maxLat: maxLat + latPad, } } function mapPoint(place: MapContext['pagePlaces'][number], bounds: ReturnType) { const lonRange = bounds.maxLon - bounds.minLon || 1 const latRange = bounds.maxLat - bounds.minLat || 1 return { x: Math.max(5, Math.min(95, (((place.lon ?? bounds.minLon) - bounds.minLon) / lonRange) * 100)), y: Math.max(5, Math.min(95, 100 - (((place.lat ?? bounds.minLat) - bounds.minLat) / latRange) * 100)), } } 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 renderHostedLogin() { return (
LS Hosted Workspace

Libre Study

Sign in to load your private sessions, notes, settings, and study timeline.

{loginError ?

{loginError}

: null}
) } 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 (