Files
libre-study/src/App.tsx
T
2026-07-12 12:54:16 -05:00

2330 lines
79 KiB
TypeScript

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<string, string> = {
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<string, string>()
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<PageMotion>('idle')
const [viewMode, setViewMode] = useState<ViewMode>('notes')
const [query, setQuery] = useState('')
const [themeMode, setThemeMode] = useState<ThemeMode>('light')
const [libraryVerses, setLibraryVerses] = useState<Verse[]>([])
const [verses, setVerses] = useState<Verse[]>([])
const [translations, setTranslations] = useState<TranslationResource[]>([])
const [selectedId, setSelectedId] = useState<number | null>(null)
const [pageStartId, setPageStartId] = useState<number | null>(null)
const [selectedStrong, setSelectedStrong] = useState<string | null>(null)
const [strongDisplay, setStrongDisplay] = useState<StrongDisplayMode>('highlight')
const [tagInput, setTagInput] = useState('')
const [noteDraft, setNoteDraft] = useState('')
const [sessions, setSessions] = useState<StudySession[]>([])
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<CommentaryEntry[]>([])
const [dictionaryEntries, setDictionaryEntries] = useState<DictionaryEntry[]>([])
const [marginResourcesLoading, setMarginResourcesLoading] = useState(false)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const restoredSessionId = useRef('')
const pageMotionTimeout = useRef<number | null>(null)
const notesDragOpenTimeout = useRef<number | null>(null)
const remoteWorkspaceSaveTimeout = useRef<number | null>(null)
const readerRef = useRef<HTMLElement | null>(null)
const hydratedNoteChapters = useRef(new Set<string>())
const workspaceHydrated = useRef(false)
function applyStudySettings(settings?: Partial<StudySettings>) {
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<StudySession>) {
if (!activeSessionId) return
const updatedAt = new Date().toISOString()
setSessions((current) =>
current.map((session) =>
session.id === activeSessionId ? { ...session, ...patch, updatedAt } : session,
),
)
}
function recordTimeline(
entry: Omit<TimelineEntry, 'id' | 'at'>,
patch: Partial<StudySession> = {},
) {
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<StudySettings>(
() => ({
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<HTMLDivElement>) {
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<HTMLElement>, 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<StudySession>) {
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(
<span
key={`${verse.id}-${entry.strong}-${entry.position}`}
className={[
'strong-token',
strongDisplay,
isActive ? 'active' : '',
].filter(Boolean).join(' ')}
role="button"
tabIndex={0}
title={`${entry.surface}: ${entry.strong}`}
onClick={(event) => {
event.stopPropagation()
selectStrong(verse, entry.strong)
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
selectStrong(verse, entry.strong)
}
}}
>
<span className="strong-surface">{linkedText}</span>
{showCode ? (
<sup className="strong-code">{entry.strong}</sup>
) : null}
</span>,
)
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 (
<section className="original-language-verse" aria-label="Original language lemma line">
<header
className="margin-section-header"
role="button"
tabIndex={0}
onClick={() => {
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')
}
}}
>
<button
type="button"
className="collapse-button"
>
{isHebrew ? 'Hebrew Lemma Line' : 'Greek Lemma Line'}
</button>
<button
type="button"
className="mini-settings"
onClick={(event) => {
event.stopPropagation()
setSettingsModalOpen(true)
}}
>
Aa
</button>
</header>
{!originalCollapsed ? (
<p className="lemma-line-text" dir={direction} style={{ fontSize: `${lemmaFontSize}px` }}>
{selectedVerse.strongs.map((entry) => (
<span
key={`${entry.strong}-${entry.position}-lemma`}
className={entry.strong === selectedStrongEntry?.strong ? 'lemma-word active' : 'lemma-word'}
role="button"
tabIndex={0}
title={`${entry.surface}: ${entry.strong}`}
onClick={() => selectStrong(selectedVerse, entry.strong)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
selectStrong(selectedVerse, entry.strong)
}
}}
>
{entry.lemma || entry.surface}
</span>
))}
</p>
) : null}
</section>
)
}
function renderLinkedEnglishLine() {
if (!showLinkedLine || !selectedVerse?.strongs.length) return null
return (
<section className="english-strong-line" aria-label="English Strong's line">
<header
className="margin-section-header"
role="button"
tabIndex={0}
onClick={() => {
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')
}
}}
>
<button
type="button"
className="collapse-button"
>
English Linked Text
</button>
<button
type="button"
className="mini-settings"
onClick={(event) => {
event.stopPropagation()
setSettingsModalOpen(true)
}}
>
Aa
</button>
</header>
{!linkedCollapsed ? (
<p className="linked-line-text" style={{ fontSize: `${linkedFontSize}px` }}>
{selectedVerse.strongs.map((entry) => (
<span
key={`${entry.strong}-${entry.position}-english`}
className={entry.strong === selectedStrongEntry?.strong ? 'linked-word active' : 'linked-word'}
role="button"
tabIndex={0}
title={`${entry.surface}: ${entry.strong}`}
onClick={() => selectStrong(selectedVerse, entry.strong)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
selectStrong(selectedVerse, entry.strong)
}
}}
>
{entry.surface}
</span>
))}
</p>
) : null}
</section>
)
}
function renderStudyNotesSection() {
if (activeSession?.footnotesEnabled === false) return null
return (
<article className="resource-card study-notes-card" aria-label="Translation study notes">
<header
className="margin-section-header"
role="button"
tabIndex={0}
onClick={() => {
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')
}
}}
>
<button type="button" className="collapse-button">Translation Study Notes</button>
<span>{selectedStudyNotes.length ? `${selectedStudyNotes.length} notes` : 'None'}</span>
</header>
{!studyNotesCollapsed ? (
selectedStudyNotes.length ? (
<div className="translation-note-list">
{selectedStudyNotes.map((note) => (
<p
key={note.id}
draggable
onDragStart={(event) => startTextDrag(event, `${note.reference} ${note.text}`)}
>
<strong>{note.reference}</strong>
<span>{note.text}</span>
</p>
))}
</div>
) : (
<p className="resource-disabled">No translation study notes for the selected verse.</p>
)
) : null}
</article>
)
}
function renderCommentarySection() {
if (!showCommentaryPanel) return null
if (activeSession?.commentaryEnabled === false) {
return (
<article className="resource-card">
<header className="margin-section-header">
<button type="button" className="collapse-button">Commentary</button>
<span>Off</span>
</header>
<p className="resource-disabled">Commentary is disabled for this session.</p>
</article>
)
}
return (
<article className="resource-card commentary-card" aria-label="Commentary">
<header
className="margin-section-header"
role="button"
tabIndex={0}
onClick={() => {
setCommentaryCollapsed((current) => !current)
recordDisplaySetting('Toggled commentary accordion')
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setCommentaryCollapsed((current) => !current)
recordDisplaySetting('Toggled commentary accordion')
}
}}
>
<button type="button" className="collapse-button">Commentary</button>
<span>{commentaryEntries.length ? 'MHCC' : marginResourcesLoading ? 'Loading' : 'None'}</span>
</header>
{!commentaryCollapsed ? (
commentaryEntries.length ? (
<div className="commentary-list">
{commentaryEntries.map((entry) => (
<section
key={entry.id}
draggable
onDragStart={(event) =>
startTextDrag(event, `${entry.reference}\n${entry.text}\n- ${entry.source}`)
}
>
<h3>{entry.reference}</h3>
<p>{entry.text}</p>
<small>{entry.source}</small>
</section>
))}
</div>
) : (
<p className="resource-disabled">
{marginResourcesLoading ? 'Loading commentary...' : 'No commentary matched this verse.'}
</p>
)
) : null}
</article>
)
}
function renderDictionarySection() {
if (!showDictionaryPanel) return null
if (activeSession?.dictionaryEnabled === false) {
return (
<article className="resource-card">
<header className="margin-section-header">
<button type="button" className="collapse-button">Dictionary</button>
<span>Off</span>
</header>
<p className="resource-disabled">Dictionary lookup is disabled for this session.</p>
</article>
)
}
return (
<article className="resource-card dictionary-card" aria-label="Dictionary">
<header
className="margin-section-header"
role="button"
tabIndex={0}
onClick={() => {
setDictionaryCollapsed((current) => !current)
recordDisplaySetting('Toggled dictionary accordion')
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setDictionaryCollapsed((current) => !current)
recordDisplaySetting('Toggled dictionary accordion')
}
}}
>
<button type="button" className="collapse-button">Dictionary</button>
<span>{dictionaryEntries.length ? `${dictionaryEntries.length} terms` : marginResourcesLoading ? 'Loading' : 'None'}</span>
</header>
{!dictionaryCollapsed ? (
dictionaryEntries.length ? (
<div className="dictionary-list">
{dictionaryEntries.slice(0, 6).map((entry) => (
<section
key={entry.id}
draggable
onDragStart={(event) =>
startTextDrag(
event,
`${entry.term}\n${entry.definitions
.map((definition) => `${definition.sourceName}: ${definition.text}`)
.join('\n\n')}`,
)
}
>
<h3>{entry.term}</h3>
{entry.definitions.slice(0, 2).map((definition) => (
<p key={`${entry.id}-${definition.source}`}>
<strong>{definition.sourceName}</strong>
{definition.text}
</p>
))}
</section>
))}
</div>
) : (
<p className="resource-disabled">
{marginResourcesLoading ? 'Loading dictionary...' : 'No dictionary terms matched this verse.'}
</p>
)
) : null}
</article>
)
}
function renderBiblePage(label = activeTranslation?.abbreviation ?? 'Bible') {
if (!selectedVerse) return null
return (
<article className="bible-page" aria-label={`${label} Bible page`}>
<header>
<span>{label}</span>
<strong>{reference(selectedVerse)}</strong>
<span>{verses.length} verses loaded</span>
</header>
<div className="page-columns">
{pageVerses.map((verse) => (
<p
key={verse.id}
className={verse.id === selectedVerse.id ? 'page-verse selected' : 'page-verse'}
draggable
onDragStart={(event) => startTextDrag(event, `${reference(verse)} ${verse.text}`)}
onClick={() => selectVerse(verse)}
>
<sup>{verse.verse}</sup>
{renderVerseText(verse)}
</p>
))}
</div>
</article>
)
}
function renderPageTurner() {
return (
<nav className="page-turner" aria-label="Page navigation">
<button type="button" onClick={() => shiftPage(-1)}>
Previous page
</button>
<div className="page-position">
<span>{pageRange}</span>
<div className="chapter-tabs" aria-label="Chapter selector">
{chapters.map((chapter) => (
<button
key={chapter}
type="button"
className={chapter === selectedVerse?.chapter ? 'active' : ''}
style={{ fontSize: `${Math.max(8, Math.min(11, 13 - chapters.length / 20))}px` }}
onClick={() => selectedVerse && jumpTo(selectedVerse.book, chapter, 1)}
>
{chapter}
</button>
))}
</div>
</div>
<button type="button" onClick={() => shiftPage(1)}>
Next page
</button>
</nav>
)
}
function renderStudyMargin() {
if (!selectedVerse) return null
return (
<aside className="study-margin" aria-label="Study margin">
<div className="panel-header">
<h2>Study Margin</h2>
<span>
{activeSession?.strongsEnabled === false
? 'links off'
: `${selectedVerse.strongs.length} links`}
</span>
</div>
<p className="margin-reference">{reference(selectedVerse)}</p>
{activeSession?.strongsEnabled === false ? (
<p className="resource-disabled">Strong's links are disabled for this session.</p>
) : (
<div className="margin-resource-grid">
{renderOriginalLanguageLine()}
{renderLinkedEnglishLine()}
{showDefinitionPanel && selectedStrongEntry ? (
<article className="strong-focus" aria-label="Strong's definition">
<header
className="margin-section-header"
role="button"
tabIndex={0}
onClick={() => {
setDefinitionCollapsed((current) => !current)
recordDisplaySetting('Toggled Strong definition accordion')
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setDefinitionCollapsed((current) => !current)
recordDisplaySetting('Toggled Strong definition accordion')
}
}}
>
<button
type="button"
className="collapse-button"
>
Definition
</button>
<span>{selectedStrongEntry.language}</span>
</header>
{!definitionCollapsed ? (
<>
<div>
<strong>{selectedStrongEntry.strong}</strong>
<span>{selectedStrongEntry.language}</span>
</div>
<h4>{selectedStrongEntry.lemma}</h4>
<p className="translit">{selectedStrongEntry.transliteration}</p>
<p>{selectedStrongEntry.definition}</p>
<button type="button" onClick={() => searchStrong(selectedStrongEntry.strong)}>
Show matching verses
</button>
</>
) : null}
</article>
) : null}
</div>
)}
<div className="margin-resource-grid">
{renderStudyNotesSection()}
{renderCommentarySection()}
{renderDictionarySection()}
</div>
</aside>
)
}
function renderFootnoteStrip() {
if (activeSession?.footnotesEnabled === false) {
return (
<section className="footnote-strip">
<strong>Footnotes</strong>
<span>Footnotes are disabled for this session.</span>
</section>
)
}
const visibleFootnotes = selectedFootnotes.length ? selectedFootnotes : pageFootnotes.slice(0, 4)
return (
<section className="footnote-strip">
<strong>Footnotes</strong>
{visibleFootnotes.length ? (
<div className="footnote-list">
{visibleFootnotes.map((note) => (
<p
key={note.id}
role="button"
tabIndex={0}
onClick={() => 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)
}
}}
>
<span>{note.reference}</span>
{note.text}
</p>
))}
</div>
) : (
<span>No footnotes for the selected verse.</span>
)}
</section>
)
}
function renderBookTabs() {
if (!selectedVerse) return null
return (
<nav className={`book-tabs ${pageMotion}`} aria-label="Bible book tabs">
{visibleBookTabs.map((book) => (
<button
key={book}
type="button"
className={book === selectedVerse.book ? 'active' : ''}
title={book}
onClick={() => jumpTo(book, 1, 1)}
>
{abbreviateBook(book)}
</button>
))}
</nav>
)
}
function renderTimelineModal() {
if (!timelineModalOpen) return null
return (
<div className="modal-backdrop" role="presentation" onMouseDown={() => setTimelineModalOpen(false)}>
<section
className="modal-panel timeline-modal"
role="dialog"
aria-modal="true"
aria-label="Study timeline"
onMouseDown={(event) => event.stopPropagation()}
>
<header>
<div>
<span className="eyebrow">Timeline</span>
<h2>{activeSession?.title ?? 'Study Session'}</h2>
</div>
<button type="button" className="close-button" onClick={() => setTimelineModalOpen(false)}>
Close
</button>
</header>
{activeSession?.timeline.length ? (
<div className="timeline-canvas">
<ol>
{[...activeSession.timeline].reverse().map((entry) => (
<li key={entry.id}>
<span className="timeline-dot" />
<button
type="button"
disabled={!entry.reference}
onClick={() => {
restoreTimelineEntry(entry)
setTimelineModalOpen(false)
}}
>
<time>{formatTimelineTime(entry.at)}</time>
<strong>{entry.label}</strong>
{entry.reference ? <span>{entry.reference}</span> : null}
{entry.detail ? <small>{entry.detail}</small> : null}
</button>
</li>
))}
</ol>
</div>
) : (
<div className="empty-timeline">
<span className="note-helper-figure" aria-hidden="true">
<span className="helper-head" />
<span className="helper-body" />
<span className="helper-notebook" />
<span className="helper-pencil" />
</span>
<p>This session has not recorded timeline events yet.</p>
</div>
)}
</section>
</div>
)
}
function renderSessionModal() {
if (!sessionModalOpen) return null
return (
<div className="modal-backdrop" role="presentation" onMouseDown={() => setSessionModalOpen(false)}>
<section
className="modal-panel session-modal"
role="dialog"
aria-modal="true"
aria-label="Session management"
onMouseDown={(event) => event.stopPropagation()}
>
<header>
<div>
<span className="eyebrow">Session</span>
<h2>Manage Study Session</h2>
</div>
<button type="button" className="close-button" onClick={() => setSessionModalOpen(false)}>
Close
</button>
</header>
<div className="modal-grid">
<section className="modal-section">
<h3>Current Session</h3>
<label>
Active session
<select value={activeSessionId} onChange={(event) => setActiveSessionId(event.target.value)}>
{sessions.map((session) => (
<option key={session.id} value={session.id}>
{session.title}
</option>
))}
</select>
</label>
<label>
Session title
<input
value={activeSession?.title ?? ''}
onChange={(event) => renameActiveSession(event.target.value)}
/>
</label>
<dl className="session-facts">
<div>
<dt>Translation</dt>
<dd>{activeTranslation?.abbreviation ?? activeSession?.baseTranslation ?? 'n/a'}</dd>
</div>
<div>
<dt>Current page</dt>
<dd>{pageRange}</dd>
</div>
<div>
<dt>Timeline</dt>
<dd>{activeSession?.timeline.length ?? 0} events</dd>
</div>
<div>
<dt>Started</dt>
<dd>{activeSession ? new Date(activeSession.createdAt).toLocaleString() : 'Not started'}</dd>
</div>
</dl>
<div className="resource-toggles">
<label>
<input
type="checkbox"
checked={activeSession?.strongsEnabled ?? true}
onChange={(event) => setActiveSessionOption({ strongsEnabled: event.target.checked })}
/>
Strong's links
</label>
<label>
<input
type="checkbox"
checked={activeSession?.footnotesEnabled ?? true}
onChange={(event) => setActiveSessionOption({ footnotesEnabled: event.target.checked })}
/>
Footnotes
</label>
<label>
<input
type="checkbox"
checked={activeSession?.commentaryEnabled ?? true}
onChange={(event) => setActiveSessionOption({ commentaryEnabled: event.target.checked })}
/>
Commentary
</label>
<label>
<input
type="checkbox"
checked={activeSession?.dictionaryEnabled ?? true}
onChange={(event) => setActiveSessionOption({ dictionaryEnabled: event.target.checked })}
/>
Dictionary
</label>
<label>
<input
type="checkbox"
checked={activeSession?.timelineEnabled ?? true}
onChange={(event) => setActiveSessionOption({ timelineEnabled: event.target.checked })}
/>
Record timeline
</label>
</div>
<button type="button" onClick={exportActiveSession}>
Export session
</button>
</section>
<section className="modal-section">
<h3>New Session</h3>
<label>
Session title
<input value={newSessionTitle} onChange={(event) => setNewSessionTitle(event.target.value)} />
</label>
<label>
Base translation
<select
value={newSessionBaseTranslation}
onChange={(event) => setNewSessionBaseTranslation(event.target.value)}
>
{translations.map((translation) => (
<option key={translation.id} value={translation.id}>
{translation.shortTitle}
</option>
))}
</select>
</label>
<div className="resource-toggles">
<label>
<input
type="checkbox"
checked={newSessionStrongs}
onChange={(event) => setNewSessionStrongs(event.target.checked)}
/>
Include Strong's links
</label>
<label>
<input
type="checkbox"
checked={newSessionFootnotes}
onChange={(event) => setNewSessionFootnotes(event.target.checked)}
/>
Include footnotes
</label>
<label>
<input
type="checkbox"
checked={newSessionCommentary}
onChange={(event) => setNewSessionCommentary(event.target.checked)}
/>
Include commentary
</label>
<label>
<input
type="checkbox"
checked={newSessionDictionary}
onChange={(event) => setNewSessionDictionary(event.target.checked)}
/>
Include dictionary
</label>
<label>
<input
type="checkbox"
checked={newSessionTimeline}
onChange={(event) => setNewSessionTimeline(event.target.checked)}
/>
Record timeline
</label>
</div>
<button type="button" onClick={startNewSession}>
Create session
</button>
</section>
</div>
</section>
</div>
)
}
function renderAboutModal() {
if (!aboutModalOpen) return null
return (
<div className="modal-backdrop" role="presentation" onMouseDown={() => setAboutModalOpen(false)}>
<section
className="modal-panel about-modal"
role="dialog"
aria-modal="true"
aria-label="About Libre Study"
onMouseDown={(event) => event.stopPropagation()}
>
<header>
<div>
<span className="eyebrow">About</span>
<h2>Libre Study</h2>
</div>
<button type="button" className="close-button" onClick={() => setAboutModalOpen(false)}>
Close
</button>
</header>
<p>
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.
</p>
<dl className="about-list">
<div>
<dt>Project</dt>
<dd>Christ IT / Libre Study</dd>
</div>
<div>
<dt>Current Bible package</dt>
<dd>
{activeTranslation
? `${activeTranslation.title}. ${activeTranslation.licenseName}`
: 'LibreBible translation package loaded from the local catalog.'}
</dd>
</div>
<div>
<dt>Software stack</dt>
<dd>Tauri, TypeScript, React, SQLite, Rust, Vite.</dd>
</div>
<div>
<dt>Source data catalog</dt>
<dd>LibreBible packages are maintained separately so this app and future sites can consume the same resources.</dd>
</div>
</dl>
<div className="about-links">
<a href="https://christit.com">ChristIT.com</a>
<a href="https://git.christit.com/libre-study/libre-study">Libre Study Git</a>
<a href="https://git.christit.com/libre-study/libre-bible-data">LibreBible Data Git</a>
</div>
</section>
</div>
)
}
function renderSettingsModal() {
if (!settingsModalOpen) return null
return (
<div className="modal-backdrop" role="presentation" onMouseDown={() => setSettingsModalOpen(false)}>
<section
className="modal-panel settings-modal"
role="dialog"
aria-modal="true"
aria-label="Study display settings"
onMouseDown={(event) => event.stopPropagation()}
>
<header>
<div>
<span className="eyebrow">Settings</span>
<h2>Study Display</h2>
</div>
<button type="button" className="close-button" onClick={() => setSettingsModalOpen(false)}>
Close
</button>
</header>
<div className="settings-grid">
<section className="modal-section">
<h3>Theme</h3>
<div className="segmented-control" aria-label="Theme mode">
<button
type="button"
className={themeMode === 'light' ? 'active' : ''}
onClick={() => changeThemeMode('light')}
>
Light
</button>
<button
type="button"
className={themeMode === 'dark' ? 'active' : ''}
onClick={() => changeThemeMode('dark')}
>
Dark
</button>
</div>
</section>
<section className="modal-section">
<h3>Original Language Line</h3>
<label>
Text size
<input
type="range"
min="13"
max="26"
value={lemmaFontSize}
onChange={(event) => setLemmaFontSize(Number(event.target.value))}
/>
<span>{lemmaFontSize}px</span>
</label>
<label className="checkbox-row">
<input
type="checkbox"
checked={showOriginalLine}
onChange={(event) => {
setShowOriginalLine(event.target.checked)
recordDisplaySetting(
event.target.checked
? 'Show original language line'
: 'Hide original language line',
)
}}
/>
Show original language line
</label>
</section>
<section className="modal-section">
<h3>English Strong's Line</h3>
<label>
Text size
<input
type="range"
min="12"
max="23"
value={linkedFontSize}
onChange={(event) => setLinkedFontSize(Number(event.target.value))}
/>
<span>{linkedFontSize}px</span>
</label>
<label className="checkbox-row">
<input
type="checkbox"
checked={showLinkedLine}
onChange={(event) => {
setShowLinkedLine(event.target.checked)
recordDisplaySetting(
event.target.checked ? 'Show English linked text' : 'Hide English linked text',
)
}}
/>
Show English linked text
</label>
</section>
<section className="modal-section">
<h3>Definition Panel</h3>
<label className="checkbox-row">
<input
type="checkbox"
checked={showDefinitionPanel}
onChange={(event) => {
setShowDefinitionPanel(event.target.checked)
recordDisplaySetting(
event.target.checked ? 'Show Strong definition' : 'Hide Strong definition',
)
}}
/>
Show expanded definition
</label>
</section>
<section className="modal-section">
<h3>Reference Panels</h3>
<label className="checkbox-row">
<input
type="checkbox"
checked={showCommentaryPanel}
onChange={(event) => {
setShowCommentaryPanel(event.target.checked)
recordDisplaySetting(
event.target.checked ? 'Show commentary panel' : 'Hide commentary panel',
)
}}
/>
Show commentary
</label>
<label className="checkbox-row">
<input
type="checkbox"
checked={showDictionaryPanel}
onChange={(event) => {
setShowDictionaryPanel(event.target.checked)
recordDisplaySetting(
event.target.checked ? 'Show dictionary panel' : 'Hide dictionary panel',
)
}}
/>
Show dictionary
</label>
</section>
</div>
</section>
</div>
)
}
function renderNotesDrawer() {
if (!selectedVerse) return null
return (
<aside
className={[
'notes-drawer',
notesDrawerOpen || notesPinned ? 'open' : '',
notesPinned ? 'pinned' : '',
].filter(Boolean).join(' ')}
aria-label="Notes drawer"
onDragEnter={(event) => {
event.preventDefault()
scheduleNotesDragOpen()
}}
onDragLeave={clearNotesDragOpen}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault()
clearNotesDragOpen()
appendNoteClip(event.dataTransfer.getData('text/plain'))
setNotesDrawerOpen(true)
}}
>
<button
type="button"
className="notes-drawer-grip"
aria-label={notesDrawerOpen || notesPinned ? 'Close notes drawer' : 'Open notes drawer'}
onClick={() => {
if (notesPinned) return
setNotesDrawerOpen((current) => !current)
}}
>
<span>N</span>
<span>O</span>
<span>T</span>
<span>E</span>
<span>S</span>
</button>
<div className="notes-workspace">
<header>
<div>
<span className="eyebrow">Notes</span>
<h2>Study Notes</h2>
</div>
<button
type="button"
className="pin-notes"
onClick={() => {
const nextPinned = !notesPinned
setNotesPinned(nextPinned)
setNotesDrawerOpen(nextPinned)
recordDisplaySetting(nextPinned ? 'Pinned notes drawer' : 'Unpinned notes drawer')
}}
>
{notesPinned ? 'Unpin' : 'Pin'}
</button>
</header>
<textarea
value={noteDraft}
onChange={(event) => updateNoteDraft(event.target.value)}
placeholder="Write sermon notes, study observations, or drag clippings here later."
/>
<div className="tag-editor">
<input
value={tagInput}
onChange={(event) => setTagInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
addTag().catch((reason) => setError(String(reason)))
}
}}
placeholder="prophecy, sermon, doctrine"
/>
<button type="button" onClick={() => addTag().catch((reason) => setError(String(reason)))}>
Tag verse
</button>
</div>
<div className="reader-tags">
{selectedVerse.tags.map((tag) => (
<span key={tag.id} style={{ backgroundColor: tag.color }}>
{tag.label}
</span>
))}
</div>
<div className="notes-quick-panels">
<button
type="button"
className="note-helper"
onClick={() => setTimelineModalOpen(true)}
aria-label="Open study timeline"
>
<span className="note-helper-figure" aria-hidden="true">
<span className="helper-head" />
<span className="helper-body" />
<span className="helper-notebook" />
<span className="helper-pencil" />
</span>
<span>
<strong>Study timeline</strong>
<small>{activeSession?.timeline.length ?? 0} events recorded</small>
</span>
</button>
<button type="button" className="note-placeholder">
<strong>Study tools</strong>
<small>Placeholder</small>
</button>
</div>
</div>
</aside>
)
}
const splitTemplate = `${splitPercent}fr 6px ${100 - splitPercent}fr`
return (
<main
className={[
'app-shell',
railOpen ? 'menu-open' : '',
notesPinned ? 'notes-pinned' : '',
themeMode === 'dark' ? 'theme-dark' : 'theme-light',
].filter(Boolean).join(' ')}
>
<aside
className="tool-rail"
aria-label="Main tools"
onMouseEnter={() => setRailOpen(true)}
onMouseLeave={() => setRailOpen(false)}
onFocus={() => setRailOpen(true)}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) setRailOpen(false)
}}
>
<button type="button" className="menu-button" title="Menu" onClick={() => setRailOpen((current) => !current)}>
<span />
<span />
<span />
</button>
<div className="app-identity" aria-label="Libre Study">
<span className="brand-mark">LS</span>
<span className="rail-copy">
<strong>Libre Study</strong>
<small>Free Bible study by Christ IT</small>
</span>
</div>
<p className="rail-description">
A readable Bible page, study margin, notes workspace, and linked public Bible data.
</p>
<button type="button" title="Session management" onClick={() => setSessionModalOpen(true)}>
<span className="rail-icon">S</span>
<span className="rail-copy"><strong>Sessions</strong><small>Save, export, and configure</small></span>
</button>
<button type="button" title="Settings" onClick={() => setSettingsModalOpen(true)}>
<span className="rail-icon">A</span>
<span className="rail-copy"><strong>Settings</strong><small>Study display controls</small></span>
</button>
<button type="button" className="rail-bottom" title="About Libre Study" onClick={() => setAboutModalOpen(true)}>
<span className="rail-icon">?</span>
<span className="rail-copy"><strong>About</strong><small>Project, licenses, and links</small></span>
</button>
</aside>
<section className="reader" ref={readerRef}>
{loading ? (
<div className="status-panel">Loading local library...</div>
) : error ? (
<div className="status-panel error">{error}</div>
) : selectedVerse ? (
<>
<header
className="top-bar"
style={{ gridTemplateColumns: splitTemplate }}
>
<div className="bible-top-tools">
<div className="page-controls" aria-label="Page selectors">
<label>
Translation
<select
value={activeSession?.baseTranslation ?? defaultTranslationId}
onChange={(event) =>
changeTranslation(event.target.value).catch((reason) => setError(String(reason)))
}
>
{translations.map((translation) => (
<option key={translation.id} value={translation.id}>
{translation.abbreviation}
</option>
))}
</select>
</label>
<label>
Book
<select
value={selectedVerse.book}
onChange={(event) => jumpTo(event.target.value, 1, 1)}
>
{books.map((book) => (
<option key={book} value={book}>
{book}
</option>
))}
</select>
</label>
<label>
Chapter
<select
value={selectedVerse.chapter}
onChange={(event) =>
jumpTo(selectedVerse.book, Number(event.target.value), 1)
}
>
{chapters.map((chapter) => (
<option key={chapter} value={chapter}>
{chapter}
</option>
))}
</select>
</label>
<label>
Verse
<select
value={selectedVerse.verse}
onChange={(event) =>
jumpTo(
selectedVerse.book,
selectedVerse.chapter,
Number(event.target.value),
)
}
>
{verseNumbers.map((verse) => (
<option key={verse} value={verse}>
{verse}
</option>
))}
</select>
</label>
<label>
Strong's
<select
value={strongDisplay}
onChange={(event) =>
setStrongDisplayMode(event.target.value as StrongDisplayMode)
}
>
<option value="off">Off</option>
<option value="highlight">Highlight</option>
<option value="numbers">Numbers</option>
</select>
</label>
</div>
<form
className="search-box"
onSubmit={(event) => {
event.preventDefault()
runSearch().catch((reason) => setError(String(reason)))
}}
>
<input
id="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={`Search ${activeTranslation?.abbreviation ?? 'Bible'}, reference, or Strong's`}
/>
<button type="submit">Go</button>
</form>
</div>
<div className="split-spacer" aria-hidden="true" />
<div className="study-top-tools" aria-label="Study margin tools">
<button type="button" onClick={() => setSettingsModalOpen(true)}>
Margin settings
</button>
</div>
</header>
<div
className={`study-desk ${viewMode}`}
style={{ gridTemplateColumns: splitTemplate }}
>
{viewMode === 'compare' ? (
<div className="page-system compare-system" aria-label="Compare translations">
<div className="compare-pages">
{renderBiblePage()}
<article className="bible-page compare-placeholder" aria-label="Second translation placeholder">
<header>
<span>Second Translation</span>
<strong>Not loaded</strong>
</header>
<div>
<h2>Add another translation to compare side by side.</h2>
<p>
This pane is reserved for the next LibreBible translation package.
It will use the same book, chapter, and verse position as the KJV page.
</p>
</div>
</article>
</div>
</div>
) : (
<>
<div className={`page-system ${viewMode} ${pageMotion}`}>
{renderBiblePage()}
{renderFootnoteStrip()}
{renderPageTurner()}
{renderBookTabs()}
</div>
<div
className="split-resizer"
role="separator"
aria-label="Resize Bible and study margin"
aria-orientation="vertical"
onMouseDown={beginSplitResize}
/>
{renderStudyMargin()}
</>
)}
</div>
</>
) : (
<div className="status-panel">No verses matched that search.</div>
)}
</section>
{renderSessionModal()}
{renderAboutModal()}
{renderSettingsModal()}
{renderTimelineModal()}
{renderNotesDrawer()}
</main>
)
}
export default App