1940 lines
65 KiB
TypeScript
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'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
|