Files
libre-study/src/App.tsx
T

1940 lines
65 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react'
import {
addTagToVerse,
getCommentaryForVerse,
getDictionaryEntriesForTerms,
getLibraryState,
searchVerses,
} from './studyClient'
import type { DragEvent, ReactNode } from 'react'
import type { CommentaryEntry, DictionaryEntry, Verse } from './types'
import './App.css'
const tagColors = ['#2f6f5e', '#735c0f', '#8a3ffc', '#b42318', '#245ba7']
const pageSize = 34
const sessionStorageKey = 'libre-study.sessions.v1'
const activeSessionStorageKey = 'libre-study.active-session.v1'
const studySettingsStorageKey = 'libre-study.study-settings.v1'
const timelineLimit = 200
type ViewMode = 'page' | 'study' | 'notes' | 'compare'
type StrongDisplayMode = 'off' | 'highlight' | 'numbers'
type PageMotion = 'idle' | 'turn-forward' | 'turn-back'
type TimelineEntryType = 'verse' | 'strong' | 'footnote' | 'search' | 'navigation' | 'view' | 'setting'
type TimelineEntry = {
id: string
at: string
type: TimelineEntryType
label: string
reference?: string
detail?: string
}
type StudySession = {
id: string
title: string
baseTranslation: string
footnotesEnabled: boolean
strongsEnabled: boolean
commentaryEnabled: boolean
dictionaryEnabled: boolean
timelineEnabled: boolean
createdAt: string
updatedAt: string
selectedId: number | null
pageStartId: number | null
viewMode: ViewMode
strongDisplay: StrongDisplayMode
noteDraft: string
timeline: TimelineEntry[]
}
function reference(verse: Verse) {
return `${verse.book} ${verse.chapter}:${verse.verse}`
}
function createSession(title = 'Study Session'): StudySession {
const now = new Date().toISOString()
const id =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`
return {
id,
title,
baseTranslation: 'kjv-eng-kjv2006',
footnotesEnabled: true,
strongsEnabled: true,
commentaryEnabled: true,
dictionaryEnabled: true,
timelineEnabled: true,
createdAt: now,
updatedAt: now,
selectedId: null,
pageStartId: null,
viewMode: 'notes',
strongDisplay: 'highlight',
noteDraft: '',
timeline: [],
}
}
function timelineId() {
return `${Date.now()}-${Math.random().toString(36).slice(2)}`
}
function formatTimelineTime(value: string) {
return new Date(value).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
}
function abbreviateBook(book: string) {
const abbreviations: Record<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 [pageMotion, setPageMotion] = useState<PageMotion>('idle')
const [viewMode, setViewMode] = useState<ViewMode>('notes')
const [query, setQuery] = useState('')
const [libraryVerses, setLibraryVerses] = useState<Verse[]>([])
const [verses, setVerses] = useState<Verse[]>([])
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 [newSessionFootnotes, setNewSessionFootnotes] = useState(true)
const [newSessionStrongs, setNewSessionStrongs] = useState(true)
const [newSessionCommentary, setNewSessionCommentary] = useState(true)
const [newSessionDictionary, setNewSessionDictionary] = useState(true)
const [newSessionTimeline, setNewSessionTimeline] = useState(true)
const [lemmaFontSize, setLemmaFontSize] = useState(17)
const [linkedFontSize, setLinkedFontSize] = useState(15)
const [originalCollapsed, setOriginalCollapsed] = useState(false)
const [linkedCollapsed, setLinkedCollapsed] = useState(false)
const [definitionCollapsed, setDefinitionCollapsed] = useState(false)
const [commentaryCollapsed, setCommentaryCollapsed] = useState(false)
const [dictionaryCollapsed, setDictionaryCollapsed] = useState(false)
const [commentaryEntries, setCommentaryEntries] = useState<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)
async function loadLibrary() {
setError(null)
const state = await getLibraryState()
setLibraryVerses(state.verses)
setVerses(state.verses)
const firstId = state.verses[0]?.id ?? null
setSelectedId((current) => current ?? firstId)
setPageStartId((current) => current ?? firstId)
}
function patchActiveSession(patch: Partial<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)
setVerses(results)
const firstResult = results[0]
setSelectedId(firstResult?.id ?? null)
setPageStartId(firstResult ? findPageStartId(firstResult) : null)
recordTimeline(
{
type: 'search',
label: trimmed ? `Search: ${trimmed}` : 'Cleared search',
detail: `${results.length} matches`,
},
{
selectedId: firstResult?.id ?? null,
pageStartId: firstResult ? findPageStartId(firstResult) : null,
},
)
}
async function addTag() {
const label = tagInput.trim()
if (!selectedId || !label) return
const color = tagColors[Math.abs(label.length) % tagColors.length]
const updated = await addTagToVerse(selectedId, label, color)
setLibraryVerses((current) =>
current.map((verse) => (verse.id === updated.id ? updated : verse)),
)
setVerses((current) =>
current.map((verse) => (verse.id === updated.id ? updated : verse)),
)
setTagInput('')
}
useEffect(() => {
const savedStudySettings = localStorage.getItem(studySettingsStorageKey)
const savedSessions = localStorage.getItem(sessionStorageKey)
const savedActiveSessionId = localStorage.getItem(activeSessionStorageKey)
let nextSessions: StudySession[] = []
if (savedStudySettings) {
try {
const parsed = JSON.parse(savedStudySettings) as Partial<{
lemmaFontSize: number
linkedFontSize: number
originalCollapsed: boolean
linkedCollapsed: boolean
definitionCollapsed: boolean
}>
setLemmaFontSize(parsed.lemmaFontSize ?? 17)
setLinkedFontSize(parsed.linkedFontSize ?? 15)
setOriginalCollapsed(parsed.originalCollapsed ?? false)
setLinkedCollapsed(parsed.linkedCollapsed ?? false)
setDefinitionCollapsed(parsed.definitionCollapsed ?? false)
} catch {
localStorage.removeItem(studySettingsStorageKey)
}
}
if (savedSessions) {
try {
const parsed = JSON.parse(savedSessions) as StudySession[]
nextSessions = Array.isArray(parsed) ? parsed : []
} catch {
nextSessions = []
}
}
if (!nextSessions.length) {
nextSessions = [createSession('KJV Study Session')]
}
nextSessions = nextSessions.map((session) => ({
...createSession(session.title),
...session,
baseTranslation: session.baseTranslation ?? 'kjv-eng-kjv2006',
footnotesEnabled: session.footnotesEnabled ?? true,
strongsEnabled: session.strongsEnabled ?? true,
commentaryEnabled: session.commentaryEnabled ?? true,
dictionaryEnabled: session.dictionaryEnabled ?? true,
timelineEnabled: session.timelineEnabled ?? true,
}))
setSessions(nextSessions)
setActiveSessionId(
nextSessions.some((session) => session.id === savedActiveSessionId)
? savedActiveSessionId ?? nextSessions[0].id
: nextSessions[0].id,
)
loadLibrary()
.catch((reason) => setError(String(reason)))
.finally(() => setLoading(false))
}, [])
useEffect(() => {
if (sessions.length) {
localStorage.setItem(sessionStorageKey, JSON.stringify(sessions))
}
}, [sessions])
useEffect(() => {
if (activeSessionId) {
localStorage.setItem(activeSessionStorageKey, activeSessionId)
}
}, [activeSessionId])
useEffect(() => {
localStorage.setItem(
studySettingsStorageKey,
JSON.stringify({
lemmaFontSize,
linkedFontSize,
originalCollapsed,
linkedCollapsed,
definitionCollapsed,
}),
)
}, [definitionCollapsed, lemmaFontSize, linkedCollapsed, linkedFontSize, originalCollapsed])
useEffect(() => {
if (restoredSessionId.current === activeSessionId) return
const activeSession = sessions.find((session) => session.id === activeSessionId)
if (!activeSession) return
restoredSessionId.current = activeSessionId
setViewMode(activeSession.viewMode)
setStrongDisplay(activeSession.strongDisplay)
setNoteDraft(activeSession.noteDraft)
setSelectedId((current) => activeSession.selectedId ?? current)
setPageStartId((current) => activeSession.pageStartId ?? current)
setSelectedStrong(null)
}, [activeSessionId, sessions])
const selectedVerse = useMemo(
() =>
libraryVerses.find((verse) => verse.id === selectedId) ??
verses.find((verse) => verse.id === selectedId) ??
verses[0],
[libraryVerses, selectedId, verses],
)
const selectedStrongEntry = useMemo(() => {
if (!selectedVerse?.strongs.length) return null
return (
selectedVerse.strongs.find((entry) => entry.strong === selectedStrong) ??
selectedVerse.strongs[0]
)
}, [selectedStrong, selectedVerse])
const activeSession = useMemo(
() => sessions.find((session) => session.id === activeSessionId) ?? sessions[0],
[activeSessionId, sessions],
)
useEffect(() => {
let cancelled = false
async function loadMarginResources() {
if (!selectedVerse) {
setCommentaryEntries([])
setDictionaryEntries([])
return
}
setMarginResourcesLoading(true)
try {
const [nextCommentary, nextDictionary] = await Promise.all([
activeSession?.commentaryEnabled === false
? Promise.resolve([])
: getCommentaryForVerse(selectedVerse),
activeSession?.dictionaryEnabled === false
? Promise.resolve([])
: getDictionaryEntriesForTerms(dictionaryTermsForVerse(selectedVerse)),
])
if (cancelled) return
setCommentaryEntries(nextCommentary)
setDictionaryEntries(nextDictionary)
} catch (reason) {
if (!cancelled) setError(String(reason))
} finally {
if (!cancelled) setMarginResourcesLoading(false)
}
}
loadMarginResources()
return () => {
cancelled = true
}
}, [activeSession?.commentaryEnabled, activeSession?.dictionaryEnabled, selectedVerse])
const pageAnchors = useMemo(() => {
const source = libraryVerses.length ? libraryVerses : verses
const anchors: Verse[] = []
let index = 0
while (index < source.length) {
const book = source[index].book
let bookVerseIndex = 0
while (index < source.length && source[index].book === book) {
if (bookVerseIndex % pageSize === 0) {
anchors.push(source[index])
}
bookVerseIndex += 1
index += 1
}
}
return anchors
}, [libraryVerses, verses])
const pageVerses = useMemo(() => {
if (!selectedVerse) return []
const source = libraryVerses.length ? libraryVerses : verses
const pageStartIndex = source.findIndex((verse) => verse.id === pageStartId)
const selectedIndex = source.findIndex((verse) => verse.id === selectedVerse.id)
const start = Math.max(0, pageStartIndex >= 0 ? pageStartIndex : selectedIndex)
const book = source[start]?.book
return source
.slice(start, start + pageSize)
.filter((verse) => verse.book === book)
}, [libraryVerses, pageStartId, selectedVerse, verses])
const pageRange = pageVerses.length
? `${reference(pageVerses[0])} - ${reference(pageVerses[pageVerses.length - 1])}`
: 'No page loaded'
const selectedFootnotes = selectedVerse?.footnotes ?? []
const pageFootnotes = useMemo(
() => pageVerses.flatMap((verse) => verse.footnotes ?? []),
[pageVerses],
)
const books = useMemo(
() => Array.from(new Set(libraryVerses.map((verse) => verse.book))),
[libraryVerses],
)
const visibleBookTabs = useMemo(() => {
if (!selectedVerse || !books.length) return []
const visibleCount = 24
const selectedIndex = Math.max(0, books.indexOf(selectedVerse.book))
const halfWindow = Math.floor(visibleCount / 2)
const maxStart = Math.max(0, books.length - visibleCount)
const start = Math.min(Math.max(0, selectedIndex - halfWindow), maxStart)
return books.slice(start, start + visibleCount)
}, [books, selectedVerse])
const chapters = useMemo(() => {
if (!selectedVerse) return []
return Array.from(
new Set(
libraryVerses
.filter((verse) => verse.book === selectedVerse.book)
.map((verse) => verse.chapter),
),
)
}, [libraryVerses, selectedVerse])
const verseNumbers = useMemo(() => {
if (!selectedVerse) return []
return libraryVerses
.filter(
(verse) =>
verse.book === selectedVerse.book && verse.chapter === selectedVerse.chapter,
)
.map((verse) => verse.verse)
}, [libraryVerses, selectedVerse])
function searchStrong(strong: string) {
setQuery(strong)
setSelectedStrong(strong)
recordTimeline({
type: 'strong',
label: `Strong's ${strong}`,
reference: selectedVerse ? reference(selectedVerse) : undefined,
detail: 'Opened matching verses search',
})
runSearch(strong).catch((reason) => setError(String(reason)))
}
function jumpTo(book: string, chapter: number, verse: number) {
const next =
libraryVerses.find(
(entry) =>
entry.book === book && entry.chapter === chapter && entry.verse === verse,
) ??
libraryVerses.find((entry) => entry.book === book && entry.chapter === chapter) ??
libraryVerses.find((entry) => entry.book === book)
if (!next) return
const currentBookIndex = selectedVerse ? books.indexOf(selectedVerse.book) : -1
const nextBookIndex = books.indexOf(next.book)
triggerPageMotion(nextBookIndex >= currentBookIndex ? 'turn-forward' : 'turn-back')
setQuery('')
setSelectedStrong(null)
setVerses(libraryVerses)
setSelectedId(next.id)
const nextPageStartId = findPageStartId(next)
setPageStartId(nextPageStartId)
recordTimeline(
{
type: 'navigation',
label: reference(next),
reference: reference(next),
detail: 'Jumped from selector',
},
{ selectedId: next.id, pageStartId: nextPageStartId },
)
}
function shiftPage(direction: -1 | 1) {
if (!selectedVerse) return
const currentStartIndex = pageAnchors.findIndex((verse) => verse.id === pageStartId)
const selectedPageStartId = findPageStartId(selectedVerse)
const selectedIndex = pageAnchors.findIndex((verse) => verse.id === selectedPageStartId)
const index = currentStartIndex >= 0 ? currentStartIndex : selectedIndex
const next =
pageAnchors[Math.min(pageAnchors.length - 1, Math.max(0, index + direction))]
if (!next) return
triggerPageMotion(direction > 0 ? 'turn-forward' : 'turn-back')
setPageStartId(next.id)
setSelectedId(next.id)
setSelectedStrong(null)
recordTimeline(
{
type: 'navigation',
label: direction > 0 ? 'Next page' : 'Previous page',
reference: reference(next),
},
{ selectedId: next.id, pageStartId: next.id },
)
}
function findPageStartId(verse: Verse) {
const source = libraryVerses.length ? libraryVerses : verses
const index = source.findIndex((entry) => entry.id === verse.id)
if (index < 0) return pageStartId ?? verse.id
let anchor = source[index]
for (const pageAnchor of pageAnchors) {
const anchorIndex = source.findIndex((entry) => entry.id === pageAnchor.id)
if (anchorIndex > index) break
if (pageAnchor.book === verse.book) {
anchor = pageAnchor
}
}
return anchor.id
}
function selectVerse(verse: Verse) {
setSelectedId(verse.id)
setSelectedStrong(null)
recordTimeline(
{
type: 'verse',
label: reference(verse),
reference: reference(verse),
},
{ selectedId: verse.id },
)
}
function selectStrong(verse: Verse, strong: string) {
setSelectedId(verse.id)
setSelectedStrong(strong)
recordTimeline(
{
type: 'strong',
label: strong,
reference: reference(verse),
},
{ selectedId: verse.id },
)
}
function setStrongDisplayMode(nextStrongDisplay: StrongDisplayMode) {
setStrongDisplay(nextStrongDisplay)
recordTimeline(
{
type: 'setting',
label: `Strong's display: ${nextStrongDisplay}`,
},
{ strongDisplay: nextStrongDisplay },
)
}
function updateNoteDraft(value: string) {
setNoteDraft(value)
patchActiveSession({ noteDraft: value })
}
function appendNoteClip(value: string) {
const clip = value.trim()
if (!clip) return
const nextValue = noteDraft.trim() ? `${noteDraft.trim()}\n\n${clip}` : clip
updateNoteDraft(nextValue)
}
function startTextDrag(event: DragEvent<HTMLElement>, value: string) {
event.dataTransfer.setData('text/plain', value)
event.dataTransfer.effectAllowed = 'copy'
setNotesDrawerOpen(true)
}
function renameActiveSession(title: string) {
patchActiveSession({ title })
}
function startNewSession() {
const nextSession = {
...createSession(newSessionTitle.trim() || `Study Session ${sessions.length + 1}`),
footnotesEnabled: newSessionFootnotes,
strongsEnabled: newSessionStrongs,
commentaryEnabled: newSessionCommentary,
dictionaryEnabled: newSessionDictionary,
timelineEnabled: newSessionTimeline,
}
setSessions((current) => [nextSession, ...current])
setActiveSessionId(nextSession.id)
setSessionModalOpen(false)
}
function setActiveSessionOption(patch: Partial<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 (!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)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setOriginalCollapsed((current) => !current)
}
}}
>
<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 (!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)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setLinkedCollapsed((current) => !current)
}
}}
>
<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 renderCommentarySection() {
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)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setCommentaryCollapsed((current) => !current)
}
}}
>
<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 (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)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setDictionaryCollapsed((current) => !current)
}
}}
>
<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 = 'KJV') {
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>
<span>King James Version - {pageRange}</span>
<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>
) : (
<>
{renderOriginalLanguageLine()}
{renderLinkedEnglishLine()}
{selectedStrongEntry ? (
<article className="strong-focus" aria-label="Strong's definition">
<header
className="margin-section-header"
role="button"
tabIndex={0}
onClick={() => setDefinitionCollapsed((current) => !current)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setDefinitionCollapsed((current) => !current)
}
}}
>
<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}
</>
)}
{renderCommentarySection()}
{renderDictionarySection()}
</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>KJV</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="kjv-eng-kjv2006" onChange={() => null}>
<option value="kjv-eng-kjv2006">King James Version</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>King James Version, public domain KJV text package with Strong's-linked study data.</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>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={!originalCollapsed}
onChange={(event) => setOriginalCollapsed(!event.target.checked)}
/>
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={!linkedCollapsed}
onChange={(event) => setLinkedCollapsed(!event.target.checked)}
/>
Show English linked text
</label>
</section>
<section className="modal-section">
<h3>Definition Panel</h3>
<label className="checkbox-row">
<input
type="checkbox"
checked={!definitionCollapsed}
onChange={(event) => setDefinitionCollapsed(!event.target.checked)}
/>
Show expanded definition
</label>
</section>
</div>
</section>
</div>
)
}
function renderNotesDrawer() {
if (!selectedVerse) return null
return (
<aside
className={notesDrawerOpen ? 'notes-drawer open' : 'notes-drawer'}
aria-label="Notes drawer"
onMouseEnter={() => setNotesDrawerOpen(true)}
onMouseLeave={() => setNotesDrawerOpen(false)}
onDragEnter={(event) => {
event.preventDefault()
setNotesDrawerOpen(true)
}}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault()
appendNoteClip(event.dataTransfer.getData('text/plain'))
setNotesDrawerOpen(true)
}}
>
<div className="notes-drawer-grip" aria-hidden="true">
<span>N</span>
<span>O</span>
<span>T</span>
<span>E</span>
<span>S</span>
</div>
<div className="notes-workspace">
<header>
<span className="eyebrow">Notes</span>
<h2>Study Notes</h2>
</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>
)
}
return (
<main className={railOpen ? 'app-shell menu-open' : 'app-shell'}>
<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">
{loading ? (
<div className="status-panel">Loading local library...</div>
) : error ? (
<div className="status-panel error">{error}</div>
) : selectedVerse ? (
<>
<header className="top-bar">
<div className="bible-top-tools">
<div className="page-controls" aria-label="Page selectors">
<label>
Translation
<select value="kjv-eng-kjv2006" onChange={() => null}>
<option value="kjv-eng-kjv2006">KJV</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 KJV, reference, or Strong&apos;s'
/>
<button type="submit">Go</button>
</form>
</div>
<div className="study-top-tools" aria-label="Study margin tools">
<span>Study margin tools</span>
</div>
</header>
<div className={`study-desk ${viewMode}`}>
{viewMode === 'compare' ? (
<div className="page-system compare-system" aria-label="Compare translations">
<div className="compare-pages">
{renderBiblePage('KJV')}
<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('KJV')}
{renderFootnoteStrip()}
{renderPageTurner()}
{renderBookTabs()}
</div>
{renderStudyMargin()}
</>
)}
</div>
</>
) : (
<div className="status-panel">No verses matched that search.</div>
)}
</section>
{renderSessionModal()}
{renderAboutModal()}
{renderSettingsModal()}
{renderTimelineModal()}
{renderNotesDrawer()}
</main>
)
}
export default App