2782 lines
94 KiB
TypeScript
2782 lines
94 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import {
|
|
addTagToVerse,
|
|
getCommentaryForVerse,
|
|
getAudioContextForVerse,
|
|
getAudioObjectUrl,
|
|
getDictionaryEntriesForTerms,
|
|
getLibraryState,
|
|
getMapContextForVerses,
|
|
getTranslationNotesForVerses,
|
|
getTranslations,
|
|
searchVerses,
|
|
} from './studyClient'
|
|
import {
|
|
createSession,
|
|
defaultTranslationId,
|
|
getAuthClient,
|
|
loadHostedSession,
|
|
loadWorkspaceSnapshot,
|
|
loginHostedWorkspace,
|
|
logoutHostedWorkspace,
|
|
saveWorkspaceSnapshot,
|
|
timelineId,
|
|
timelineLimit,
|
|
} from './workspace'
|
|
import type { DragEvent, FormEvent, MouseEvent as ReactMouseEvent, ReactNode } from 'react'
|
|
import type {
|
|
AudioBibleContext,
|
|
AudioBibleSegment,
|
|
CommentaryEntry,
|
|
DictionaryEntry,
|
|
MapContext,
|
|
TranslationResource,
|
|
Verse,
|
|
} from './types'
|
|
import type {
|
|
StrongDisplayMode,
|
|
HostedUser,
|
|
StudySession,
|
|
StudySettings,
|
|
ThemeMode,
|
|
TimelineEntry,
|
|
ViewMode,
|
|
} from './workspace'
|
|
import './App.css'
|
|
|
|
const tagColors = ['#2f6f5e', '#735c0f', '#8a3ffc', '#b42318', '#245ba7']
|
|
const pageSize = 34
|
|
type PageMotion = 'idle' | 'turn-forward' | 'turn-back'
|
|
|
|
function reference(verse: Verse) {
|
|
return `${verse.book} ${verse.chapter}:${verse.verse}`
|
|
}
|
|
|
|
function replaceChangedVerses(verses: Verse[], hydrate: (verse: Verse) => Verse) {
|
|
let changed = false
|
|
const next = verses.map((verse) => {
|
|
const hydrated = hydrate(verse)
|
|
if (hydrated !== verse) changed = true
|
|
return hydrated
|
|
})
|
|
return changed ? next : verses
|
|
}
|
|
|
|
function formatTimelineTime(value: string) {
|
|
return new Date(value).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
|
}
|
|
|
|
function abbreviateBook(book: string) {
|
|
const abbreviations: Record<string, string> = {
|
|
Genesis: 'Gen',
|
|
Exodus: 'Ex',
|
|
Leviticus: 'Lev',
|
|
Numbers: 'Num',
|
|
Deuteronomy: 'Deut',
|
|
Joshua: 'Josh',
|
|
Judges: 'Judg',
|
|
Ruth: 'Ruth',
|
|
'1 Samuel': '1 Sam',
|
|
'2 Samuel': '2 Sam',
|
|
'1 Kings': '1 Kgs',
|
|
'2 Kings': '2 Kgs',
|
|
'1 Chronicles': '1 Chr',
|
|
'2 Chronicles': '2 Chr',
|
|
Ezra: 'Ezra',
|
|
Nehemiah: 'Neh',
|
|
Esther: 'Est',
|
|
Job: 'Job',
|
|
Psalms: 'Ps',
|
|
Proverbs: 'Prov',
|
|
Ecclesiastes: 'Eccl',
|
|
'Song of Solomon': 'Song',
|
|
Isaiah: 'Isa',
|
|
Jeremiah: 'Jer',
|
|
Lamentations: 'Lam',
|
|
Ezekiel: 'Ezek',
|
|
Daniel: 'Dan',
|
|
Hosea: 'Hos',
|
|
Joel: 'Joel',
|
|
Amos: 'Amos',
|
|
Obadiah: 'Ob',
|
|
Jonah: 'Jon',
|
|
Micah: 'Mic',
|
|
Nahum: 'Nah',
|
|
Habakkuk: 'Hab',
|
|
Zephaniah: 'Zeph',
|
|
Haggai: 'Hag',
|
|
Zechariah: 'Zech',
|
|
Malachi: 'Mal',
|
|
Matthew: 'Matt',
|
|
Mark: 'Mark',
|
|
Luke: 'Luke',
|
|
John: 'John',
|
|
Acts: 'Acts',
|
|
Romans: 'Rom',
|
|
'1 Corinthians': '1 Cor',
|
|
'2 Corinthians': '2 Cor',
|
|
Galatians: 'Gal',
|
|
Ephesians: 'Eph',
|
|
Philippians: 'Phil',
|
|
Colossians: 'Col',
|
|
'1 Thessalonians': '1 Th',
|
|
'2 Thessalonians': '2 Th',
|
|
'1 Timothy': '1 Tim',
|
|
'2 Timothy': '2 Tim',
|
|
Titus: 'Titus',
|
|
Philemon: 'Phlm',
|
|
Hebrews: 'Heb',
|
|
James: 'Jas',
|
|
'1 Peter': '1 Pet',
|
|
'2 Peter': '2 Pet',
|
|
'1 John': '1 Jn',
|
|
'2 John': '2 Jn',
|
|
'3 John': '3 Jn',
|
|
Jude: 'Jude',
|
|
Revelation: 'Rev',
|
|
}
|
|
|
|
return abbreviations[book] ?? book.slice(0, 4)
|
|
}
|
|
|
|
function dictionaryTermsForVerse(verse: Verse) {
|
|
const stopWords = new Set([
|
|
'a',
|
|
'an',
|
|
'and',
|
|
'are',
|
|
'as',
|
|
'at',
|
|
'be',
|
|
'but',
|
|
'by',
|
|
'for',
|
|
'from',
|
|
'he',
|
|
'her',
|
|
'him',
|
|
'his',
|
|
'i',
|
|
'in',
|
|
'is',
|
|
'it',
|
|
'of',
|
|
'on',
|
|
'or',
|
|
'that',
|
|
'the',
|
|
'their',
|
|
'them',
|
|
'they',
|
|
'this',
|
|
'to',
|
|
'unto',
|
|
'was',
|
|
'were',
|
|
'with',
|
|
'ye',
|
|
'you',
|
|
])
|
|
const strongSurfaces = verse.strongs.map((entry) => entry.surface)
|
|
const textWords = [...verse.text.matchAll(/[A-Za-z][A-Za-z'-]*/g)].map((match) => match[0])
|
|
const unique = new Map<string, string>()
|
|
|
|
for (const term of [...strongSurfaces, ...textWords]) {
|
|
const normalized = term.toLowerCase().replace(/[^a-z0-9\s'-]/g, '').trim()
|
|
if (!normalized || stopWords.has(normalized) || normalized.length < 3) continue
|
|
unique.set(normalized, term)
|
|
}
|
|
|
|
return [...unique.values()].slice(0, 28)
|
|
}
|
|
|
|
function App() {
|
|
const [railOpen, setRailOpen] = useState(false)
|
|
const [sessionModalOpen, setSessionModalOpen] = useState(false)
|
|
const [aboutModalOpen, setAboutModalOpen] = useState(false)
|
|
const [timelineModalOpen, setTimelineModalOpen] = useState(false)
|
|
const [settingsModalOpen, setSettingsModalOpen] = useState(false)
|
|
const [notesDrawerOpen, setNotesDrawerOpen] = useState(false)
|
|
const [notesPinned, setNotesPinned] = useState(false)
|
|
const [pageMotion, setPageMotion] = useState<PageMotion>('idle')
|
|
const [viewMode, setViewMode] = useState<ViewMode>('notes')
|
|
const [query, setQuery] = useState('')
|
|
const [themeMode, setThemeMode] = useState<ThemeMode>('light')
|
|
const [libraryVerses, setLibraryVerses] = useState<Verse[]>([])
|
|
const [verses, setVerses] = useState<Verse[]>([])
|
|
const [translations, setTranslations] = useState<TranslationResource[]>([])
|
|
const [selectedId, setSelectedId] = useState<number | null>(null)
|
|
const [pageStartId, setPageStartId] = useState<number | null>(null)
|
|
const [selectedStrong, setSelectedStrong] = useState<string | null>(null)
|
|
const [strongDisplay, setStrongDisplay] = useState<StrongDisplayMode>('highlight')
|
|
const [tagInput, setTagInput] = useState('')
|
|
const [noteDraft, setNoteDraft] = useState('')
|
|
const [sessions, setSessions] = useState<StudySession[]>([])
|
|
const [activeSessionId, setActiveSessionId] = useState('')
|
|
const [newSessionTitle, setNewSessionTitle] = useState('New KJV Study')
|
|
const [newSessionBaseTranslation, setNewSessionBaseTranslation] = useState(defaultTranslationId)
|
|
const [newSessionFootnotes, setNewSessionFootnotes] = useState(true)
|
|
const [newSessionStrongs, setNewSessionStrongs] = useState(true)
|
|
const [newSessionCommentary, setNewSessionCommentary] = useState(true)
|
|
const [newSessionDictionary, setNewSessionDictionary] = useState(true)
|
|
const [newSessionTimeline, setNewSessionTimeline] = useState(true)
|
|
const [lemmaFontSize, setLemmaFontSize] = useState(17)
|
|
const [linkedFontSize, setLinkedFontSize] = useState(15)
|
|
const [originalCollapsed, setOriginalCollapsed] = useState(false)
|
|
const [linkedCollapsed, setLinkedCollapsed] = useState(false)
|
|
const [definitionCollapsed, setDefinitionCollapsed] = useState(false)
|
|
const [studyNotesCollapsed, setStudyNotesCollapsed] = useState(false)
|
|
const [commentaryCollapsed, setCommentaryCollapsed] = useState(false)
|
|
const [dictionaryCollapsed, setDictionaryCollapsed] = useState(false)
|
|
const [showOriginalLine, setShowOriginalLine] = useState(true)
|
|
const [showLinkedLine, setShowLinkedLine] = useState(true)
|
|
const [showDefinitionPanel, setShowDefinitionPanel] = useState(true)
|
|
const [showCommentaryPanel, setShowCommentaryPanel] = useState(true)
|
|
const [showDictionaryPanel, setShowDictionaryPanel] = useState(true)
|
|
const [splitPercent, setSplitPercent] = useState(50)
|
|
const [commentaryEntries, setCommentaryEntries] = useState<CommentaryEntry[]>([])
|
|
const [dictionaryEntries, setDictionaryEntries] = useState<DictionaryEntry[]>([])
|
|
const [mapContext, setMapContext] = useState<MapContext>({
|
|
selectedPlaces: [],
|
|
pagePlaces: [],
|
|
relatedRoutes: [],
|
|
})
|
|
const [audioContext, setAudioContext] = useState<AudioBibleContext | null>(null)
|
|
const [audioResourcesLoading, setAudioResourcesLoading] = useState(false)
|
|
const [audioStatus, setAudioStatus] = useState('Ready')
|
|
const [audioPlayingSegmentId, setAudioPlayingSegmentId] = useState<string | null>(null)
|
|
const [audioVolume, setAudioVolume] = useState(0.85)
|
|
const [audioSpeed, setAudioSpeed] = useState(1)
|
|
const [marginResourcesLoading, setMarginResourcesLoading] = useState(false)
|
|
const [mapResourcesLoading, setMapResourcesLoading] = useState(false)
|
|
const [authRequired, setAuthRequired] = useState(false)
|
|
const [hostedUser, setHostedUser] = useState<HostedUser | null>(null)
|
|
const [loginUsername, setLoginUsername] = useState('')
|
|
const [loginPassword, setLoginPassword] = useState('')
|
|
const [loginBusy, setLoginBusy] = useState(false)
|
|
const [loginError, setLoginError] = useState<string | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const restoredSessionId = useRef('')
|
|
const pageMotionTimeout = useRef<number | null>(null)
|
|
const notesDragOpenTimeout = useRef<number | null>(null)
|
|
const remoteWorkspaceSaveTimeout = useRef<number | null>(null)
|
|
const readerRef = useRef<HTMLElement | null>(null)
|
|
const audioRef = useRef<HTMLAudioElement | null>(null)
|
|
const audioQueueRef = useRef<AudioBibleSegment[]>([])
|
|
const audioQueueIndexRef = useRef(0)
|
|
const hydratedNoteChapters = useRef(new Set<string>())
|
|
const workspaceHydrated = useRef(false)
|
|
|
|
function applyStudySettings(settings?: Partial<StudySettings>) {
|
|
if (!settings) return
|
|
|
|
setLemmaFontSize(settings.lemmaFontSize ?? 17)
|
|
setLinkedFontSize(settings.linkedFontSize ?? 15)
|
|
setOriginalCollapsed(settings.originalCollapsed ?? false)
|
|
setLinkedCollapsed(settings.linkedCollapsed ?? false)
|
|
setDefinitionCollapsed(settings.definitionCollapsed ?? false)
|
|
setStudyNotesCollapsed(settings.studyNotesCollapsed ?? false)
|
|
setCommentaryCollapsed(settings.commentaryCollapsed ?? false)
|
|
setDictionaryCollapsed(settings.dictionaryCollapsed ?? false)
|
|
setShowOriginalLine(settings.showOriginalLine ?? true)
|
|
setShowLinkedLine(settings.showLinkedLine ?? true)
|
|
setShowDefinitionPanel(settings.showDefinitionPanel ?? true)
|
|
setShowCommentaryPanel(settings.showCommentaryPanel ?? true)
|
|
setShowDictionaryPanel(settings.showDictionaryPanel ?? true)
|
|
setSplitPercent(settings.splitPercent ?? 50)
|
|
setNotesPinned(settings.notesPinned ?? false)
|
|
setThemeMode(settings.themeMode ?? 'light')
|
|
}
|
|
|
|
async function loadLibrary(translationId = activeSession?.baseTranslation ?? defaultTranslationId) {
|
|
setError(null)
|
|
const state = await getLibraryState(translationId)
|
|
setLibraryVerses(state.verses)
|
|
setVerses(state.verses)
|
|
const firstId = state.verses[0]?.id ?? null
|
|
setSelectedId((current) => current ?? firstId)
|
|
setPageStartId((current) => current ?? firstId)
|
|
}
|
|
|
|
function patchActiveSession(patch: Partial<StudySession>) {
|
|
if (!activeSessionId) return
|
|
const updatedAt = new Date().toISOString()
|
|
setSessions((current) =>
|
|
current.map((session) =>
|
|
session.id === activeSessionId ? { ...session, ...patch, updatedAt } : session,
|
|
),
|
|
)
|
|
}
|
|
|
|
function recordTimeline(
|
|
entry: Omit<TimelineEntry, 'id' | 'at'>,
|
|
patch: Partial<StudySession> = {},
|
|
) {
|
|
if (!activeSessionId) return
|
|
const at = new Date().toISOString()
|
|
const nextEntry: TimelineEntry = { id: timelineId(), at, ...entry }
|
|
|
|
setSessions((current) =>
|
|
current.map((session) =>
|
|
session.id === activeSessionId
|
|
? {
|
|
...session,
|
|
selectedId,
|
|
pageStartId,
|
|
viewMode,
|
|
strongDisplay,
|
|
noteDraft,
|
|
...patch,
|
|
updatedAt: at,
|
|
timeline:
|
|
session.timelineEnabled === false
|
|
? session.timeline
|
|
: [nextEntry, ...session.timeline].slice(0, timelineLimit),
|
|
}
|
|
: session,
|
|
),
|
|
)
|
|
}
|
|
|
|
function playPageTurnSound() {
|
|
if (!window.AudioContext) return
|
|
|
|
try {
|
|
const audio = new window.AudioContext()
|
|
const oscillator = audio.createOscillator()
|
|
const gain = audio.createGain()
|
|
|
|
oscillator.type = 'triangle'
|
|
oscillator.frequency.setValueAtTime(120, audio.currentTime)
|
|
oscillator.frequency.exponentialRampToValueAtTime(42, audio.currentTime + 0.08)
|
|
gain.gain.setValueAtTime(0.0001, audio.currentTime)
|
|
gain.gain.exponentialRampToValueAtTime(0.06, audio.currentTime + 0.012)
|
|
gain.gain.exponentialRampToValueAtTime(0.0001, audio.currentTime + 0.11)
|
|
oscillator.connect(gain)
|
|
gain.connect(audio.destination)
|
|
oscillator.start()
|
|
oscillator.stop(audio.currentTime + 0.12)
|
|
window.setTimeout(() => audio.close().catch(() => null), 180)
|
|
} catch {
|
|
// Page sound is intentionally best-effort.
|
|
}
|
|
}
|
|
|
|
function triggerPageMotion(direction: PageMotion) {
|
|
if (pageMotionTimeout.current) window.clearTimeout(pageMotionTimeout.current)
|
|
setPageMotion('idle')
|
|
window.requestAnimationFrame(() => {
|
|
setPageMotion(direction)
|
|
playPageTurnSound()
|
|
pageMotionTimeout.current = window.setTimeout(() => setPageMotion('idle'), 520)
|
|
})
|
|
}
|
|
|
|
async function runSearch(nextQuery = query) {
|
|
setError(null)
|
|
const trimmed = nextQuery.trim()
|
|
setSelectedStrong(/^[gh]\d+$/i.test(trimmed) ? trimmed.toUpperCase() : null)
|
|
const results = await searchVerses(nextQuery, activeSession?.baseTranslation ?? defaultTranslationId)
|
|
setVerses(results)
|
|
const firstResult = results[0]
|
|
setSelectedId(firstResult?.id ?? null)
|
|
setPageStartId(firstResult ? findPageStartId(firstResult) : null)
|
|
recordTimeline(
|
|
{
|
|
type: 'search',
|
|
label: trimmed ? `Search: ${trimmed}` : 'Cleared search',
|
|
detail: `${results.length} matches`,
|
|
},
|
|
{
|
|
selectedId: firstResult?.id ?? null,
|
|
pageStartId: firstResult ? findPageStartId(firstResult) : null,
|
|
},
|
|
)
|
|
}
|
|
|
|
async function addTag() {
|
|
const label = tagInput.trim()
|
|
if (!selectedId || !label) return
|
|
|
|
const color = tagColors[Math.abs(label.length) % tagColors.length]
|
|
const updated = await addTagToVerse(
|
|
selectedId,
|
|
label,
|
|
color,
|
|
activeSession?.baseTranslation ?? defaultTranslationId,
|
|
)
|
|
|
|
setLibraryVerses((current) =>
|
|
current.map((verse) => (verse.id === updated.id ? updated : verse)),
|
|
)
|
|
setVerses((current) =>
|
|
current.map((verse) => (verse.id === updated.id ? updated : verse)),
|
|
)
|
|
setTagInput('')
|
|
}
|
|
|
|
const studySettingsSnapshot = useMemo<StudySettings>(
|
|
() => ({
|
|
lemmaFontSize,
|
|
linkedFontSize,
|
|
originalCollapsed,
|
|
linkedCollapsed,
|
|
definitionCollapsed,
|
|
studyNotesCollapsed,
|
|
commentaryCollapsed,
|
|
dictionaryCollapsed,
|
|
showOriginalLine,
|
|
showLinkedLine,
|
|
showDefinitionPanel,
|
|
showCommentaryPanel,
|
|
showDictionaryPanel,
|
|
splitPercent,
|
|
notesPinned,
|
|
themeMode,
|
|
}),
|
|
[
|
|
commentaryCollapsed,
|
|
definitionCollapsed,
|
|
dictionaryCollapsed,
|
|
lemmaFontSize,
|
|
linkedCollapsed,
|
|
linkedFontSize,
|
|
notesPinned,
|
|
originalCollapsed,
|
|
showCommentaryPanel,
|
|
showDefinitionPanel,
|
|
showDictionaryPanel,
|
|
showLinkedLine,
|
|
showOriginalLine,
|
|
splitPercent,
|
|
studyNotesCollapsed,
|
|
themeMode,
|
|
],
|
|
)
|
|
|
|
async function restoreWorkspace(cancelled = () => false) {
|
|
setLoading(true)
|
|
setError(null)
|
|
setAuthRequired(false)
|
|
workspaceHydrated.current = false
|
|
|
|
if (getAuthClient().enabled) {
|
|
const session = await loadHostedSession()
|
|
if (cancelled()) return
|
|
setHostedUser(session.user)
|
|
if (!session.authenticated) {
|
|
setAuthRequired(true)
|
|
setLoading(false)
|
|
return
|
|
}
|
|
}
|
|
|
|
const workspace = await loadWorkspaceSnapshot()
|
|
const selectedSession =
|
|
workspace.sessions.find((session) => session.id === workspace.activeSessionId) ?? workspace.sessions[0]
|
|
|
|
if (cancelled()) return
|
|
applyStudySettings(workspace.studySettings)
|
|
setSessions(workspace.sessions)
|
|
setActiveSessionId(workspace.activeSessionId)
|
|
getTranslations().then(setTranslations).catch((reason) => setError(String(reason)))
|
|
|
|
await loadLibrary(selectedSession.baseTranslation)
|
|
if (cancelled()) return
|
|
setLoading(false)
|
|
window.setTimeout(() => {
|
|
workspaceHydrated.current = true
|
|
}, 0)
|
|
}
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
|
|
restoreWorkspace(() => cancelled).catch((reason) => {
|
|
if (!cancelled) {
|
|
setError(String(reason))
|
|
setLoading(false)
|
|
}
|
|
})
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!workspaceHydrated.current || !sessions.length || !activeSessionId) {
|
|
return
|
|
}
|
|
|
|
if (remoteWorkspaceSaveTimeout.current) {
|
|
window.clearTimeout(remoteWorkspaceSaveTimeout.current)
|
|
}
|
|
|
|
remoteWorkspaceSaveTimeout.current = window.setTimeout(() => {
|
|
saveWorkspaceSnapshot({
|
|
sessions,
|
|
activeSessionId,
|
|
studySettings: studySettingsSnapshot,
|
|
}).catch((reason) => {
|
|
console.warn(
|
|
getAuthClient().enabled
|
|
? 'Unable to save hosted workspace.'
|
|
: 'Unable to save local workspace.',
|
|
reason,
|
|
)
|
|
})
|
|
remoteWorkspaceSaveTimeout.current = null
|
|
}, 650)
|
|
|
|
return () => {
|
|
if (remoteWorkspaceSaveTimeout.current) {
|
|
window.clearTimeout(remoteWorkspaceSaveTimeout.current)
|
|
remoteWorkspaceSaveTimeout.current = null
|
|
}
|
|
}
|
|
}, [
|
|
activeSessionId,
|
|
sessions,
|
|
studySettingsSnapshot,
|
|
])
|
|
|
|
useEffect(() => {
|
|
if (restoredSessionId.current === activeSessionId) return
|
|
const activeSession = sessions.find((session) => session.id === activeSessionId)
|
|
if (!activeSession) return
|
|
|
|
restoredSessionId.current = activeSessionId
|
|
setViewMode(activeSession.viewMode)
|
|
setStrongDisplay(activeSession.strongDisplay)
|
|
setNoteDraft(activeSession.noteDraft)
|
|
loadLibrary(activeSession.baseTranslation).catch((reason) => setError(String(reason)))
|
|
setSelectedId((current) => activeSession.selectedId ?? current)
|
|
setPageStartId((current) => activeSession.pageStartId ?? current)
|
|
setSelectedStrong(null)
|
|
}, [activeSessionId, sessions])
|
|
|
|
const selectedVerse = useMemo(
|
|
() =>
|
|
libraryVerses.find((verse) => verse.id === selectedId) ??
|
|
verses.find((verse) => verse.id === selectedId) ??
|
|
verses[0],
|
|
[libraryVerses, selectedId, verses],
|
|
)
|
|
const selectedStrongEntry = useMemo(() => {
|
|
if (!selectedVerse?.strongs.length) return null
|
|
return (
|
|
selectedVerse.strongs.find((entry) => entry.strong === selectedStrong) ??
|
|
selectedVerse.strongs[0]
|
|
)
|
|
}, [selectedStrong, selectedVerse])
|
|
const activeSession = useMemo(
|
|
() => sessions.find((session) => session.id === activeSessionId) ?? sessions[0],
|
|
[activeSessionId, sessions],
|
|
)
|
|
const activeTranslation = useMemo(
|
|
() =>
|
|
translations.find((translation) => translation.id === activeSession?.baseTranslation) ??
|
|
translations.find((translation) => translation.id === defaultTranslationId),
|
|
[activeSession?.baseTranslation, translations],
|
|
)
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
|
|
async function loadMarginResources() {
|
|
if (!selectedVerse) {
|
|
setCommentaryEntries([])
|
|
setDictionaryEntries([])
|
|
return
|
|
}
|
|
|
|
setMarginResourcesLoading(true)
|
|
try {
|
|
const [nextCommentary, nextDictionary] = await Promise.all([
|
|
activeSession?.commentaryEnabled === false
|
|
? Promise.resolve([])
|
|
: getCommentaryForVerse(selectedVerse),
|
|
activeSession?.dictionaryEnabled === false
|
|
? Promise.resolve([])
|
|
: getDictionaryEntriesForTerms(dictionaryTermsForVerse(selectedVerse)),
|
|
])
|
|
|
|
if (cancelled) return
|
|
setCommentaryEntries(nextCommentary)
|
|
setDictionaryEntries(nextDictionary)
|
|
} catch (reason) {
|
|
if (!cancelled) setError(String(reason))
|
|
} finally {
|
|
if (!cancelled) setMarginResourcesLoading(false)
|
|
}
|
|
}
|
|
|
|
loadMarginResources()
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [activeSession?.commentaryEnabled, activeSession?.dictionaryEnabled, selectedVerse])
|
|
|
|
const pageAnchors = useMemo(() => {
|
|
const source = libraryVerses.length ? libraryVerses : verses
|
|
const anchors: Verse[] = []
|
|
let index = 0
|
|
|
|
while (index < source.length) {
|
|
const book = source[index].book
|
|
let bookVerseIndex = 0
|
|
|
|
while (index < source.length && source[index].book === book) {
|
|
if (bookVerseIndex % pageSize === 0) {
|
|
anchors.push(source[index])
|
|
}
|
|
bookVerseIndex += 1
|
|
index += 1
|
|
}
|
|
}
|
|
|
|
return anchors
|
|
}, [libraryVerses, verses])
|
|
const pageVerses = useMemo(() => {
|
|
if (!selectedVerse) return []
|
|
const source = libraryVerses.length ? libraryVerses : verses
|
|
const pageStartIndex = source.findIndex((verse) => verse.id === pageStartId)
|
|
const selectedIndex = source.findIndex((verse) => verse.id === selectedVerse.id)
|
|
const start = Math.max(0, pageStartIndex >= 0 ? pageStartIndex : selectedIndex)
|
|
const book = source[start]?.book
|
|
return source
|
|
.slice(start, start + pageSize)
|
|
.filter((verse) => verse.book === book)
|
|
}, [libraryVerses, pageStartId, selectedVerse, verses])
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
|
|
async function loadMapContext() {
|
|
if (!selectedVerse || pageVerses.length === 0) {
|
|
setMapContext({ selectedPlaces: [], pagePlaces: [], relatedRoutes: [] })
|
|
return
|
|
}
|
|
|
|
setMapResourcesLoading(true)
|
|
try {
|
|
const nextContext = await getMapContextForVerses(selectedVerse, pageVerses)
|
|
if (!cancelled) setMapContext(nextContext)
|
|
} catch (reason) {
|
|
if (!cancelled) setError(String(reason))
|
|
} finally {
|
|
if (!cancelled) setMapResourcesLoading(false)
|
|
}
|
|
}
|
|
|
|
loadMapContext()
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [pageVerses, selectedVerse])
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
|
|
async function loadAudioContext() {
|
|
if (!selectedVerse) {
|
|
setAudioContext(null)
|
|
return
|
|
}
|
|
|
|
setAudioResourcesLoading(true)
|
|
try {
|
|
const nextContext = await getAudioContextForVerse(
|
|
activeSession?.baseTranslation ?? defaultTranslationId,
|
|
selectedVerse,
|
|
)
|
|
if (!cancelled) setAudioContext(nextContext)
|
|
} catch (reason) {
|
|
if (!cancelled) {
|
|
setAudioContext(null)
|
|
setError(String(reason))
|
|
}
|
|
} finally {
|
|
if (!cancelled) setAudioResourcesLoading(false)
|
|
}
|
|
}
|
|
|
|
loadAudioContext()
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [activeSession?.baseTranslation, selectedVerse])
|
|
|
|
useEffect(() => {
|
|
if (!audioRef.current) return
|
|
audioRef.current.volume = audioVolume
|
|
audioRef.current.playbackRate = audioSpeed
|
|
}, [audioSpeed, audioVolume])
|
|
|
|
useEffect(() => {
|
|
if (!activeSession?.baseTranslation || pageVerses.length === 0) return
|
|
const chapterKeys = [
|
|
...new Set(
|
|
pageVerses.map(
|
|
(verse) => `${activeSession.baseTranslation}:${verse.book}:${verse.chapter}`,
|
|
),
|
|
),
|
|
]
|
|
const unloadedChapterKeys = chapterKeys.filter(
|
|
(chapterKey) => !hydratedNoteChapters.current.has(chapterKey),
|
|
)
|
|
if (unloadedChapterKeys.length === 0) return
|
|
for (const chapterKey of unloadedChapterKeys) hydratedNoteChapters.current.add(chapterKey)
|
|
|
|
let cancelled = false
|
|
getTranslationNotesForVerses(activeSession.baseTranslation, pageVerses)
|
|
.then((notesByVerse) => {
|
|
if (cancelled || notesByVerse.size === 0) return
|
|
const hydrate = (verse: Verse) => {
|
|
const notes = notesByVerse.get(String(verse.id))
|
|
if (!notes) return verse
|
|
if (
|
|
(verse.footnotes?.length ?? 0) === notes.footnotes.length &&
|
|
(verse.studyNotes?.length ?? 0) === notes.studyNotes.length
|
|
) {
|
|
return verse
|
|
}
|
|
return { ...verse, footnotes: notes.footnotes, studyNotes: notes.studyNotes }
|
|
}
|
|
setLibraryVerses((current) => replaceChangedVerses(current, hydrate))
|
|
setVerses((current) => replaceChangedVerses(current, hydrate))
|
|
})
|
|
.catch((reason) => setError(String(reason)))
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [activeSession?.baseTranslation, pageVerses])
|
|
|
|
const pageRange = pageVerses.length
|
|
? `${reference(pageVerses[0])} - ${reference(pageVerses[pageVerses.length - 1])}`
|
|
: 'No page loaded'
|
|
const selectedFootnotes = selectedVerse?.footnotes ?? []
|
|
const selectedStudyNotes = selectedVerse?.studyNotes ?? []
|
|
const pageFootnotes = useMemo(
|
|
() => pageVerses.flatMap((verse) => verse.footnotes ?? []),
|
|
[pageVerses],
|
|
)
|
|
const books = useMemo(
|
|
() => Array.from(new Set(libraryVerses.map((verse) => verse.book))),
|
|
[libraryVerses],
|
|
)
|
|
const visibleBookTabs = useMemo(() => {
|
|
if (!selectedVerse || !books.length) return []
|
|
const visibleCount = 24
|
|
const selectedIndex = Math.max(0, books.indexOf(selectedVerse.book))
|
|
const halfWindow = Math.floor(visibleCount / 2)
|
|
const maxStart = Math.max(0, books.length - visibleCount)
|
|
const start = Math.min(Math.max(0, selectedIndex - halfWindow), maxStart)
|
|
|
|
return books.slice(start, start + visibleCount)
|
|
}, [books, selectedVerse])
|
|
const chapters = useMemo(() => {
|
|
if (!selectedVerse) return []
|
|
return Array.from(
|
|
new Set(
|
|
libraryVerses
|
|
.filter((verse) => verse.book === selectedVerse.book)
|
|
.map((verse) => verse.chapter),
|
|
),
|
|
)
|
|
}, [libraryVerses, selectedVerse])
|
|
const verseNumbers = useMemo(() => {
|
|
if (!selectedVerse) return []
|
|
return libraryVerses
|
|
.filter(
|
|
(verse) =>
|
|
verse.book === selectedVerse.book && verse.chapter === selectedVerse.chapter,
|
|
)
|
|
.map((verse) => verse.verse)
|
|
}, [libraryVerses, selectedVerse])
|
|
|
|
function searchStrong(strong: string) {
|
|
setQuery(strong)
|
|
setSelectedStrong(strong)
|
|
recordTimeline({
|
|
type: 'strong',
|
|
label: `Strong's ${strong}`,
|
|
reference: selectedVerse ? reference(selectedVerse) : undefined,
|
|
detail: 'Opened matching verses search',
|
|
})
|
|
runSearch(strong).catch((reason) => setError(String(reason)))
|
|
}
|
|
|
|
function jumpTo(book: string, chapter: number, verse: number) {
|
|
const next =
|
|
libraryVerses.find(
|
|
(entry) =>
|
|
entry.book === book && entry.chapter === chapter && entry.verse === verse,
|
|
) ??
|
|
libraryVerses.find((entry) => entry.book === book && entry.chapter === chapter) ??
|
|
libraryVerses.find((entry) => entry.book === book)
|
|
|
|
if (!next) return
|
|
const currentBookIndex = selectedVerse ? books.indexOf(selectedVerse.book) : -1
|
|
const nextBookIndex = books.indexOf(next.book)
|
|
triggerPageMotion(nextBookIndex >= currentBookIndex ? 'turn-forward' : 'turn-back')
|
|
setQuery('')
|
|
setSelectedStrong(null)
|
|
setVerses(libraryVerses)
|
|
setSelectedId(next.id)
|
|
const nextPageStartId = findPageStartId(next)
|
|
setPageStartId(nextPageStartId)
|
|
recordTimeline(
|
|
{
|
|
type: 'navigation',
|
|
label: reference(next),
|
|
reference: reference(next),
|
|
detail: 'Jumped from selector',
|
|
},
|
|
{ selectedId: next.id, pageStartId: nextPageStartId },
|
|
)
|
|
}
|
|
|
|
async function changeTranslation(translationId: string) {
|
|
const translation =
|
|
translations.find((item) => item.id === translationId) ??
|
|
translations.find((item) => item.id === defaultTranslationId)
|
|
const state = await getLibraryState(translationId)
|
|
const firstVerse = state.verses[0] ?? null
|
|
|
|
setQuery('')
|
|
setSelectedStrong(null)
|
|
setLibraryVerses(state.verses)
|
|
setVerses(state.verses)
|
|
setSelectedId(firstVerse?.id ?? null)
|
|
setPageStartId(firstVerse?.id ?? null)
|
|
patchActiveSession({
|
|
baseTranslation: translationId,
|
|
selectedId: firstVerse?.id ?? null,
|
|
pageStartId: firstVerse?.id ?? null,
|
|
})
|
|
recordTimeline(
|
|
{
|
|
type: 'setting',
|
|
label: `Translation: ${translation?.abbreviation ?? translationId}`,
|
|
detail: translation?.title,
|
|
},
|
|
{
|
|
baseTranslation: translationId,
|
|
selectedId: firstVerse?.id ?? null,
|
|
pageStartId: firstVerse?.id ?? null,
|
|
},
|
|
)
|
|
}
|
|
|
|
function shiftPage(direction: -1 | 1) {
|
|
if (!selectedVerse) return
|
|
const currentStartIndex = pageAnchors.findIndex((verse) => verse.id === pageStartId)
|
|
const selectedPageStartId = findPageStartId(selectedVerse)
|
|
const selectedIndex = pageAnchors.findIndex((verse) => verse.id === selectedPageStartId)
|
|
const index = currentStartIndex >= 0 ? currentStartIndex : selectedIndex
|
|
const next =
|
|
pageAnchors[Math.min(pageAnchors.length - 1, Math.max(0, index + direction))]
|
|
|
|
if (!next) return
|
|
triggerPageMotion(direction > 0 ? 'turn-forward' : 'turn-back')
|
|
setPageStartId(next.id)
|
|
setSelectedId(next.id)
|
|
setSelectedStrong(null)
|
|
recordTimeline(
|
|
{
|
|
type: 'navigation',
|
|
label: direction > 0 ? 'Next page' : 'Previous page',
|
|
reference: reference(next),
|
|
},
|
|
{ selectedId: next.id, pageStartId: next.id },
|
|
)
|
|
}
|
|
|
|
function findPageStartId(verse: Verse) {
|
|
const source = libraryVerses.length ? libraryVerses : verses
|
|
const index = source.findIndex((entry) => entry.id === verse.id)
|
|
if (index < 0) return pageStartId ?? verse.id
|
|
|
|
let anchor = source[index]
|
|
for (const pageAnchor of pageAnchors) {
|
|
const anchorIndex = source.findIndex((entry) => entry.id === pageAnchor.id)
|
|
if (anchorIndex > index) break
|
|
if (pageAnchor.book === verse.book) {
|
|
anchor = pageAnchor
|
|
}
|
|
}
|
|
|
|
return anchor.id
|
|
}
|
|
|
|
function selectVerse(verse: Verse) {
|
|
setSelectedId(verse.id)
|
|
setSelectedStrong(null)
|
|
recordTimeline(
|
|
{
|
|
type: 'verse',
|
|
label: reference(verse),
|
|
reference: reference(verse),
|
|
},
|
|
{ selectedId: verse.id },
|
|
)
|
|
}
|
|
|
|
function selectStrong(verse: Verse, strong: string) {
|
|
setSelectedId(verse.id)
|
|
setSelectedStrong(strong)
|
|
recordTimeline(
|
|
{
|
|
type: 'strong',
|
|
label: strong,
|
|
reference: reference(verse),
|
|
},
|
|
{ selectedId: verse.id },
|
|
)
|
|
}
|
|
|
|
function setStrongDisplayMode(nextStrongDisplay: StrongDisplayMode) {
|
|
setStrongDisplay(nextStrongDisplay)
|
|
recordTimeline(
|
|
{
|
|
type: 'setting',
|
|
label: `Strong's display: ${nextStrongDisplay}`,
|
|
},
|
|
{ strongDisplay: nextStrongDisplay },
|
|
)
|
|
}
|
|
|
|
function recordDisplaySetting(label: string) {
|
|
recordTimeline({
|
|
type: 'setting',
|
|
label,
|
|
})
|
|
}
|
|
|
|
function changeThemeMode(nextThemeMode: ThemeMode) {
|
|
setThemeMode(nextThemeMode)
|
|
recordDisplaySetting(`Theme: ${nextThemeMode}`)
|
|
}
|
|
|
|
function beginSplitResize(event: ReactMouseEvent<HTMLDivElement>) {
|
|
event.preventDefault()
|
|
const reader = readerRef.current
|
|
if (!reader) return
|
|
|
|
const resize = (moveEvent: MouseEvent) => {
|
|
const rect = reader.getBoundingClientRect()
|
|
const next = ((moveEvent.clientX - rect.left) / rect.width) * 100
|
|
setSplitPercent(Math.min(68, Math.max(36, next)))
|
|
}
|
|
|
|
const stop = () => {
|
|
window.removeEventListener('mousemove', resize)
|
|
window.removeEventListener('mouseup', stop)
|
|
recordDisplaySetting('Adjusted Bible and study margin split')
|
|
}
|
|
|
|
window.addEventListener('mousemove', resize)
|
|
window.addEventListener('mouseup', stop, { once: true })
|
|
}
|
|
|
|
function updateNoteDraft(value: string) {
|
|
setNoteDraft(value)
|
|
patchActiveSession({ noteDraft: value })
|
|
}
|
|
|
|
function appendNoteClip(value: string) {
|
|
const clip = value.trim()
|
|
if (!clip) return
|
|
const nextValue = noteDraft.trim() ? `${noteDraft.trim()}\n\n${clip}` : clip
|
|
updateNoteDraft(nextValue)
|
|
}
|
|
|
|
function startTextDrag(event: DragEvent<HTMLElement>, value: string) {
|
|
event.dataTransfer.setData('text/plain', value)
|
|
event.dataTransfer.effectAllowed = 'copy'
|
|
}
|
|
|
|
function scheduleNotesDragOpen() {
|
|
if (notesPinned || notesDragOpenTimeout.current) return
|
|
notesDragOpenTimeout.current = window.setTimeout(() => {
|
|
setNotesDrawerOpen(true)
|
|
notesDragOpenTimeout.current = null
|
|
}, 550)
|
|
}
|
|
|
|
function clearNotesDragOpen() {
|
|
if (!notesDragOpenTimeout.current) return
|
|
window.clearTimeout(notesDragOpenTimeout.current)
|
|
notesDragOpenTimeout.current = null
|
|
}
|
|
|
|
function renameActiveSession(title: string) {
|
|
patchActiveSession({ title })
|
|
}
|
|
|
|
function startNewSession() {
|
|
const nextSession = {
|
|
...createSession(
|
|
newSessionTitle.trim() || `Study Session ${sessions.length + 1}`,
|
|
newSessionBaseTranslation,
|
|
),
|
|
footnotesEnabled: newSessionFootnotes,
|
|
strongsEnabled: newSessionStrongs,
|
|
commentaryEnabled: newSessionCommentary,
|
|
dictionaryEnabled: newSessionDictionary,
|
|
timelineEnabled: newSessionTimeline,
|
|
}
|
|
setSessions((current) => [nextSession, ...current])
|
|
setActiveSessionId(nextSession.id)
|
|
loadLibrary(nextSession.baseTranslation).catch((reason) => setError(String(reason)))
|
|
setSessionModalOpen(false)
|
|
}
|
|
|
|
function setActiveSessionOption(patch: Partial<StudySession>) {
|
|
patchActiveSession(patch)
|
|
if (patch.strongsEnabled === false) {
|
|
setStrongDisplay('off')
|
|
}
|
|
if (
|
|
typeof patch.strongsEnabled === 'boolean' ||
|
|
typeof patch.footnotesEnabled === 'boolean' ||
|
|
typeof patch.commentaryEnabled === 'boolean' ||
|
|
typeof patch.dictionaryEnabled === 'boolean'
|
|
) {
|
|
recordTimeline({ type: 'setting', label: 'Updated session resources' }, patch)
|
|
}
|
|
}
|
|
|
|
function exportActiveSession() {
|
|
if (!activeSession) return
|
|
const blob = new Blob([JSON.stringify(activeSession, null, 2)], {
|
|
type: 'application/json',
|
|
})
|
|
const url = URL.createObjectURL(blob)
|
|
const link = document.createElement('a')
|
|
link.href = url
|
|
link.download = `${activeSession.title.toLowerCase().replace(/[^a-z0-9]+/g, '-') || 'study-session'}.json`
|
|
link.click()
|
|
URL.revokeObjectURL(url)
|
|
}
|
|
|
|
async function submitHostedLogin(event: FormEvent<HTMLFormElement>) {
|
|
event.preventDefault()
|
|
setLoginBusy(true)
|
|
setLoginError(null)
|
|
try {
|
|
const session = await loginHostedWorkspace(loginUsername.trim(), loginPassword)
|
|
setHostedUser(session.user)
|
|
setLoginPassword('')
|
|
await restoreWorkspace()
|
|
} catch (reason) {
|
|
setLoginError(String(reason))
|
|
} finally {
|
|
setLoginBusy(false)
|
|
}
|
|
}
|
|
|
|
async function signOutHostedWorkspace() {
|
|
setError(null)
|
|
await logoutHostedWorkspace()
|
|
setHostedUser(null)
|
|
setSessions([])
|
|
setActiveSessionId('')
|
|
setAuthRequired(true)
|
|
setLoading(false)
|
|
workspaceHydrated.current = false
|
|
}
|
|
|
|
function restoreTimelineEntry(entry: TimelineEntry) {
|
|
if (!entry.reference) return
|
|
const [bookPart, rest] = entry.reference.split(/ (?=\d+:\d+$)/)
|
|
const [chapter, verse] = rest?.split(':').map(Number) ?? []
|
|
if (!bookPart || !chapter || !verse) return
|
|
|
|
const next = libraryVerses.find(
|
|
(entryVerse) =>
|
|
entryVerse.book === bookPart &&
|
|
entryVerse.chapter === chapter &&
|
|
entryVerse.verse === verse,
|
|
)
|
|
if (!next) return
|
|
|
|
triggerPageMotion(next.id >= (selectedVerse?.id ?? next.id) ? 'turn-forward' : 'turn-back')
|
|
setQuery('')
|
|
setSelectedStrong(null)
|
|
setVerses(libraryVerses)
|
|
setSelectedId(next.id)
|
|
if (!pageVerses.some((pageVerse) => pageVerse.id === next.id)) {
|
|
setPageStartId(findPageStartId(next))
|
|
}
|
|
}
|
|
|
|
function recordFootnote(referenceText: string, text: string) {
|
|
recordTimeline({
|
|
type: 'footnote',
|
|
label: `Footnote ${referenceText}`,
|
|
reference: selectedVerse ? reference(selectedVerse) : undefined,
|
|
detail: text,
|
|
})
|
|
}
|
|
|
|
async function playAudioQueue(segments: AudioBibleSegment[], startIndex = 0) {
|
|
if (!segments.length) return
|
|
audioQueueRef.current = segments
|
|
audioQueueIndexRef.current = startIndex
|
|
await playQueuedAudioSegment(startIndex)
|
|
}
|
|
|
|
async function playQueuedAudioSegment(index: number) {
|
|
const segment = audioQueueRef.current[index]
|
|
const audio = audioRef.current
|
|
if (!segment || !audio) return
|
|
|
|
setAudioResourcesLoading(true)
|
|
setAudioStatus(`Loading ${segment.reference}`)
|
|
try {
|
|
const url = await getAudioObjectUrl(segment)
|
|
audio.pause()
|
|
audio.src = url
|
|
audio.volume = audioVolume
|
|
audio.playbackRate = audioSpeed
|
|
await audio.play()
|
|
setAudioPlayingSegmentId(segment.id)
|
|
setAudioStatus(`Playing ${segment.reference}`)
|
|
recordTimeline({
|
|
type: 'setting',
|
|
label: `Audio: ${segment.reference}`,
|
|
reference: segment.reference,
|
|
detail: segment.audioZipFile,
|
|
})
|
|
} catch (reason) {
|
|
setAudioPlayingSegmentId(null)
|
|
setAudioStatus(`Audio unavailable: ${String(reason)}`)
|
|
} finally {
|
|
setAudioResourcesLoading(false)
|
|
}
|
|
}
|
|
|
|
function stopAudio() {
|
|
const audio = audioRef.current
|
|
audioQueueRef.current = []
|
|
audioQueueIndexRef.current = 0
|
|
if (audio) {
|
|
audio.pause()
|
|
audio.currentTime = 0
|
|
}
|
|
setAudioPlayingSegmentId(null)
|
|
setAudioStatus('Stopped')
|
|
}
|
|
|
|
function handleAudioEnded() {
|
|
const nextIndex = audioQueueIndexRef.current + 1
|
|
if (nextIndex < audioQueueRef.current.length) {
|
|
audioQueueIndexRef.current = nextIndex
|
|
playQueuedAudioSegment(nextIndex).catch((reason) => setAudioStatus(String(reason)))
|
|
return
|
|
}
|
|
|
|
audioQueueRef.current = []
|
|
audioQueueIndexRef.current = 0
|
|
setAudioPlayingSegmentId(null)
|
|
setAudioStatus('Ready')
|
|
}
|
|
|
|
function renderVerseText(verse: Verse) {
|
|
if (!activeSession?.strongsEnabled || !verse.strongs.length || strongDisplay === 'off') return verse.text
|
|
|
|
const parts: ReactNode[] = []
|
|
let cursor = 0
|
|
const lowerText = verse.text.toLowerCase()
|
|
const sortedLinks = [...verse.strongs].sort((left, right) => left.position - right.position)
|
|
|
|
sortedLinks.forEach((entry) => {
|
|
const surface = entry.surface.trim()
|
|
if (!surface) return
|
|
|
|
const index = lowerText.indexOf(surface.toLowerCase(), cursor)
|
|
if (index < 0) return
|
|
if (index > cursor) {
|
|
parts.push(verse.text.slice(cursor, index))
|
|
}
|
|
|
|
const linkedText = verse.text.slice(index, index + surface.length)
|
|
const isActive = entry.strong === selectedStrong
|
|
const showCode = strongDisplay === 'numbers' || isActive
|
|
parts.push(
|
|
<span
|
|
key={`${verse.id}-${entry.strong}-${entry.position}`}
|
|
className={[
|
|
'strong-token',
|
|
strongDisplay,
|
|
isActive ? 'active' : '',
|
|
].filter(Boolean).join(' ')}
|
|
role="button"
|
|
tabIndex={0}
|
|
title={`${entry.surface}: ${entry.strong}`}
|
|
onClick={(event) => {
|
|
event.stopPropagation()
|
|
selectStrong(verse, entry.strong)
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault()
|
|
selectStrong(verse, entry.strong)
|
|
}
|
|
}}
|
|
>
|
|
<span className="strong-surface">{linkedText}</span>
|
|
{showCode ? (
|
|
<sup className="strong-code">{entry.strong}</sup>
|
|
) : null}
|
|
</span>,
|
|
)
|
|
cursor = index + surface.length
|
|
})
|
|
|
|
if (cursor < verse.text.length) {
|
|
parts.push(verse.text.slice(cursor))
|
|
}
|
|
|
|
return parts.length ? parts : verse.text
|
|
}
|
|
|
|
function renderOriginalLanguageLine() {
|
|
if (!showOriginalLine || !selectedVerse?.strongs.length) return null
|
|
const isHebrew = selectedVerse.strongs.some((entry) => entry.strong.startsWith('H'))
|
|
const direction = isHebrew ? 'rtl' : 'ltr'
|
|
|
|
return (
|
|
<section className="original-language-verse" aria-label="Original language lemma line">
|
|
<header
|
|
className="margin-section-header"
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={() => {
|
|
setOriginalCollapsed((current) => !current)
|
|
recordDisplaySetting('Toggled original language accordion')
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault()
|
|
setOriginalCollapsed((current) => !current)
|
|
recordDisplaySetting('Toggled original language accordion')
|
|
}
|
|
}}
|
|
>
|
|
<button
|
|
type="button"
|
|
className="collapse-button"
|
|
>
|
|
{isHebrew ? 'Hebrew Lemma Line' : 'Greek Lemma Line'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="mini-settings"
|
|
onClick={(event) => {
|
|
event.stopPropagation()
|
|
setSettingsModalOpen(true)
|
|
}}
|
|
>
|
|
Aa
|
|
</button>
|
|
</header>
|
|
{!originalCollapsed ? (
|
|
<p className="lemma-line-text" dir={direction} style={{ fontSize: `${lemmaFontSize}px` }}>
|
|
{selectedVerse.strongs.map((entry) => (
|
|
<span
|
|
key={`${entry.strong}-${entry.position}-lemma`}
|
|
className={entry.strong === selectedStrongEntry?.strong ? 'lemma-word active' : 'lemma-word'}
|
|
role="button"
|
|
tabIndex={0}
|
|
title={`${entry.surface}: ${entry.strong}`}
|
|
onClick={() => selectStrong(selectedVerse, entry.strong)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault()
|
|
selectStrong(selectedVerse, entry.strong)
|
|
}
|
|
}}
|
|
>
|
|
{entry.lemma || entry.surface}
|
|
</span>
|
|
))}
|
|
</p>
|
|
) : null}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function renderLinkedEnglishLine() {
|
|
if (!showLinkedLine || !selectedVerse?.strongs.length) return null
|
|
|
|
return (
|
|
<section className="english-strong-line" aria-label="English Strong's line">
|
|
<header
|
|
className="margin-section-header"
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={() => {
|
|
setLinkedCollapsed((current) => !current)
|
|
recordDisplaySetting('Toggled English linked text accordion')
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault()
|
|
setLinkedCollapsed((current) => !current)
|
|
recordDisplaySetting('Toggled English linked text accordion')
|
|
}
|
|
}}
|
|
>
|
|
<button
|
|
type="button"
|
|
className="collapse-button"
|
|
>
|
|
English Linked Text
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="mini-settings"
|
|
onClick={(event) => {
|
|
event.stopPropagation()
|
|
setSettingsModalOpen(true)
|
|
}}
|
|
>
|
|
Aa
|
|
</button>
|
|
</header>
|
|
{!linkedCollapsed ? (
|
|
<p className="linked-line-text" style={{ fontSize: `${linkedFontSize}px` }}>
|
|
{selectedVerse.strongs.map((entry) => (
|
|
<span
|
|
key={`${entry.strong}-${entry.position}-english`}
|
|
className={entry.strong === selectedStrongEntry?.strong ? 'linked-word active' : 'linked-word'}
|
|
role="button"
|
|
tabIndex={0}
|
|
title={`${entry.surface}: ${entry.strong}`}
|
|
onClick={() => selectStrong(selectedVerse, entry.strong)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault()
|
|
selectStrong(selectedVerse, entry.strong)
|
|
}
|
|
}}
|
|
>
|
|
{entry.surface}
|
|
</span>
|
|
))}
|
|
</p>
|
|
) : null}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function renderStudyNotesSection() {
|
|
if (activeSession?.footnotesEnabled === false) return null
|
|
|
|
return (
|
|
<article className="resource-card study-notes-card" aria-label="Translation study notes">
|
|
<header
|
|
className="margin-section-header"
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={() => {
|
|
setStudyNotesCollapsed((current) => !current)
|
|
recordDisplaySetting('Toggled translation study notes accordion')
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault()
|
|
setStudyNotesCollapsed((current) => !current)
|
|
recordDisplaySetting('Toggled translation study notes accordion')
|
|
}
|
|
}}
|
|
>
|
|
<button type="button" className="collapse-button">Translation Study Notes</button>
|
|
<span>{selectedStudyNotes.length ? `${selectedStudyNotes.length} notes` : 'None'}</span>
|
|
</header>
|
|
{!studyNotesCollapsed ? (
|
|
selectedStudyNotes.length ? (
|
|
<div className="translation-note-list">
|
|
{selectedStudyNotes.map((note) => (
|
|
<p
|
|
key={note.id}
|
|
draggable
|
|
onDragStart={(event) => startTextDrag(event, `${note.reference} ${note.text}`)}
|
|
>
|
|
<strong>{note.reference}</strong>
|
|
<span>{note.text}</span>
|
|
</p>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="resource-disabled">No translation study notes for the selected verse.</p>
|
|
)
|
|
) : null}
|
|
</article>
|
|
)
|
|
}
|
|
|
|
function renderCommentarySection() {
|
|
if (!showCommentaryPanel) return null
|
|
|
|
if (activeSession?.commentaryEnabled === false) {
|
|
return (
|
|
<article className="resource-card">
|
|
<header className="margin-section-header">
|
|
<button type="button" className="collapse-button">Commentary</button>
|
|
<span>Off</span>
|
|
</header>
|
|
<p className="resource-disabled">Commentary is disabled for this session.</p>
|
|
</article>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<article className="resource-card commentary-card" aria-label="Commentary">
|
|
<header
|
|
className="margin-section-header"
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={() => {
|
|
setCommentaryCollapsed((current) => !current)
|
|
recordDisplaySetting('Toggled commentary accordion')
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault()
|
|
setCommentaryCollapsed((current) => !current)
|
|
recordDisplaySetting('Toggled commentary accordion')
|
|
}
|
|
}}
|
|
>
|
|
<button type="button" className="collapse-button">Commentary</button>
|
|
<span>{commentaryEntries.length ? 'MHCC' : marginResourcesLoading ? 'Loading' : 'None'}</span>
|
|
</header>
|
|
{!commentaryCollapsed ? (
|
|
commentaryEntries.length ? (
|
|
<div className="commentary-list">
|
|
{commentaryEntries.map((entry) => (
|
|
<section
|
|
key={entry.id}
|
|
draggable
|
|
onDragStart={(event) =>
|
|
startTextDrag(event, `${entry.reference}\n${entry.text}\n- ${entry.source}`)
|
|
}
|
|
>
|
|
<h3>{entry.reference}</h3>
|
|
<p>{entry.text}</p>
|
|
<small>{entry.source}</small>
|
|
</section>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="resource-disabled">
|
|
{marginResourcesLoading ? 'Loading commentary...' : 'No commentary matched this verse.'}
|
|
</p>
|
|
)
|
|
) : null}
|
|
</article>
|
|
)
|
|
}
|
|
|
|
function renderDictionarySection() {
|
|
if (!showDictionaryPanel) return null
|
|
|
|
if (activeSession?.dictionaryEnabled === false) {
|
|
return (
|
|
<article className="resource-card">
|
|
<header className="margin-section-header">
|
|
<button type="button" className="collapse-button">Dictionary</button>
|
|
<span>Off</span>
|
|
</header>
|
|
<p className="resource-disabled">Dictionary lookup is disabled for this session.</p>
|
|
</article>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<article className="resource-card dictionary-card" aria-label="Dictionary">
|
|
<header
|
|
className="margin-section-header"
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={() => {
|
|
setDictionaryCollapsed((current) => !current)
|
|
recordDisplaySetting('Toggled dictionary accordion')
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault()
|
|
setDictionaryCollapsed((current) => !current)
|
|
recordDisplaySetting('Toggled dictionary accordion')
|
|
}
|
|
}}
|
|
>
|
|
<button type="button" className="collapse-button">Dictionary</button>
|
|
<span>{dictionaryEntries.length ? `${dictionaryEntries.length} terms` : marginResourcesLoading ? 'Loading' : 'None'}</span>
|
|
</header>
|
|
{!dictionaryCollapsed ? (
|
|
dictionaryEntries.length ? (
|
|
<div className="dictionary-list">
|
|
{dictionaryEntries.slice(0, 6).map((entry) => (
|
|
<section
|
|
key={entry.id}
|
|
draggable
|
|
onDragStart={(event) =>
|
|
startTextDrag(
|
|
event,
|
|
`${entry.term}\n${entry.definitions
|
|
.map((definition) => `${definition.sourceName}: ${definition.text}`)
|
|
.join('\n\n')}`,
|
|
)
|
|
}
|
|
>
|
|
<h3>{entry.term}</h3>
|
|
{entry.definitions.slice(0, 2).map((definition) => (
|
|
<p key={`${entry.id}-${definition.source}`}>
|
|
<strong>{definition.sourceName}</strong>
|
|
{definition.text}
|
|
</p>
|
|
))}
|
|
</section>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="resource-disabled">
|
|
{marginResourcesLoading ? 'Loading dictionary...' : 'No dictionary terms matched this verse.'}
|
|
</p>
|
|
)
|
|
) : null}
|
|
</article>
|
|
)
|
|
}
|
|
|
|
function renderMapSection() {
|
|
const places = mapContext.pagePlaces
|
|
const selectedPlaces = mapContext.selectedPlaces
|
|
const routes = mapContext.relatedRoutes
|
|
const bounds = mapBounds(places)
|
|
|
|
return (
|
|
<article className="resource-card map-card" aria-label="Contextual maps">
|
|
<header className="margin-section-header">
|
|
<button type="button" className="collapse-button">Context Map</button>
|
|
<span>
|
|
{places.length
|
|
? `${places.length} places`
|
|
: mapResourcesLoading
|
|
? 'Loading'
|
|
: 'None'}
|
|
</span>
|
|
</header>
|
|
{places.length ? (
|
|
<>
|
|
<div className="context-map" aria-label="Visible-page place map">
|
|
<div className="map-grid" aria-hidden="true" />
|
|
{places.map((place) => {
|
|
const selected = selectedPlaces.some((item) => item.placeId === place.placeId)
|
|
const point = mapPoint(place, bounds)
|
|
return (
|
|
<button
|
|
key={place.id}
|
|
type="button"
|
|
className={selected ? 'map-marker selected' : 'map-marker'}
|
|
style={{ left: `${point.x}%`, top: `${point.y}%` }}
|
|
title={`${place.placeName}: ${place.modernLocationName}`}
|
|
>
|
|
<span>{place.placeName.slice(0, 1)}</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
<div className="place-list">
|
|
{places.slice(0, 8).map((place) => (
|
|
<section key={place.id}>
|
|
<h3>{place.placeName}</h3>
|
|
<p>
|
|
<span>{place.modernLocationName || 'Unresolved location'}</span>
|
|
<small>{place.confidenceLabel} confidence</small>
|
|
</p>
|
|
</section>
|
|
))}
|
|
</div>
|
|
{routes.length ? (
|
|
<div className="route-list">
|
|
<strong>Related routes</strong>
|
|
{routes.map((route) => (
|
|
<span key={route.id}>
|
|
{route.title}
|
|
<small>{route.mapNumber}</small>
|
|
</span>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
<p className="map-attribution">
|
|
Places: OpenBible.info. Routes: UBS Project MARBLE. Coordinates are contextual and may be approximate.
|
|
</p>
|
|
</>
|
|
) : (
|
|
<p className="resource-disabled">
|
|
{mapResourcesLoading ? 'Loading contextual map data...' : 'No mapped places matched this page.'}
|
|
</p>
|
|
)}
|
|
</article>
|
|
)
|
|
}
|
|
|
|
function renderAudioSection() {
|
|
const selectedSegment = audioContext?.selectedSegment ?? null
|
|
const chapterSegments = audioContext?.chapterPlaylist?.segments ?? []
|
|
const selectedIndex = selectedSegment
|
|
? Math.max(0, chapterSegments.findIndex((segment) => segment.id === selectedSegment.id))
|
|
: 0
|
|
|
|
return (
|
|
<article className="resource-card audio-card" aria-label="Audio Bible">
|
|
<header className="margin-section-header">
|
|
<button type="button" className="collapse-button">Audio Bible</button>
|
|
<span>
|
|
{audioResourcesLoading
|
|
? 'Loading'
|
|
: audioContext
|
|
? audioContext.shortTitle
|
|
: 'None'}
|
|
</span>
|
|
</header>
|
|
{audioContext && selectedSegment ? (
|
|
<>
|
|
<div className="audio-controls">
|
|
<button
|
|
type="button"
|
|
onClick={() => playAudioQueue([selectedSegment]).catch((reason) => setAudioStatus(String(reason)))}
|
|
>
|
|
Play verse
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
playAudioQueue(chapterSegments, 0).catch((reason) => setAudioStatus(String(reason)))
|
|
}
|
|
>
|
|
Play chapter
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
playAudioQueue(chapterSegments, selectedIndex).catch((reason) => setAudioStatus(String(reason)))
|
|
}
|
|
>
|
|
From verse
|
|
</button>
|
|
<button type="button" className="secondary-button" onClick={stopAudio}>
|
|
Stop
|
|
</button>
|
|
</div>
|
|
<div className="audio-sliders">
|
|
<label>
|
|
Volume
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max="1"
|
|
step="0.05"
|
|
value={audioVolume}
|
|
onChange={(event) => setAudioVolume(Number(event.target.value))}
|
|
/>
|
|
<span>{Math.round(audioVolume * 100)}%</span>
|
|
</label>
|
|
<label>
|
|
Speed
|
|
<select
|
|
value={audioSpeed}
|
|
onChange={(event) => setAudioSpeed(Number(event.target.value))}
|
|
>
|
|
<option value="0.75">0.75x</option>
|
|
<option value="1">1x</option>
|
|
<option value="1.25">1.25x</option>
|
|
<option value="1.5">1.5x</option>
|
|
<option value="2">2x</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<p className="audio-status">
|
|
<strong>{audioPlayingSegmentId ? 'Playing' : 'Status'}</strong>
|
|
<span>{audioStatus}</span>
|
|
</p>
|
|
<p className="map-attribution">{audioContext.attribution}</p>
|
|
<audio ref={audioRef} preload="none" onEnded={handleAudioEnded} />
|
|
</>
|
|
) : (
|
|
<p className="resource-disabled">
|
|
{audioResourcesLoading
|
|
? 'Loading audio metadata...'
|
|
: 'No audio package is attached to this translation yet.'}
|
|
</p>
|
|
)}
|
|
</article>
|
|
)
|
|
}
|
|
|
|
function mapBounds(places: MapContext['pagePlaces']) {
|
|
const points = places.filter((place) => place.lon != null && place.lat != null)
|
|
if (points.length === 0) {
|
|
return { minLon: 0, maxLon: 1, minLat: 0, maxLat: 1 }
|
|
}
|
|
|
|
const minLon = Math.min(...points.map((place) => place.lon ?? 0))
|
|
const maxLon = Math.max(...points.map((place) => place.lon ?? 0))
|
|
const minLat = Math.min(...points.map((place) => place.lat ?? 0))
|
|
const maxLat = Math.max(...points.map((place) => place.lat ?? 0))
|
|
const lonPad = Math.max(0.2, (maxLon - minLon) * 0.18)
|
|
const latPad = Math.max(0.2, (maxLat - minLat) * 0.18)
|
|
|
|
return {
|
|
minLon: minLon - lonPad,
|
|
maxLon: maxLon + lonPad,
|
|
minLat: minLat - latPad,
|
|
maxLat: maxLat + latPad,
|
|
}
|
|
}
|
|
|
|
function mapPoint(place: MapContext['pagePlaces'][number], bounds: ReturnType<typeof mapBounds>) {
|
|
const lonRange = bounds.maxLon - bounds.minLon || 1
|
|
const latRange = bounds.maxLat - bounds.minLat || 1
|
|
return {
|
|
x: Math.max(5, Math.min(95, (((place.lon ?? bounds.minLon) - bounds.minLon) / lonRange) * 100)),
|
|
y: Math.max(5, Math.min(95, 100 - (((place.lat ?? bounds.minLat) - bounds.minLat) / latRange) * 100)),
|
|
}
|
|
}
|
|
|
|
function renderBiblePage(label = activeTranslation?.abbreviation ?? 'Bible') {
|
|
if (!selectedVerse) return null
|
|
return (
|
|
<article className="bible-page" aria-label={`${label} Bible page`}>
|
|
<header>
|
|
<span>{label}</span>
|
|
<strong>{reference(selectedVerse)}</strong>
|
|
<span>{verses.length} verses loaded</span>
|
|
</header>
|
|
<div className="page-columns">
|
|
{pageVerses.map((verse) => (
|
|
<p
|
|
key={verse.id}
|
|
className={verse.id === selectedVerse.id ? 'page-verse selected' : 'page-verse'}
|
|
draggable
|
|
onDragStart={(event) => startTextDrag(event, `${reference(verse)} ${verse.text}`)}
|
|
onClick={() => selectVerse(verse)}
|
|
>
|
|
<sup>{verse.verse}</sup>
|
|
{renderVerseText(verse)}
|
|
</p>
|
|
))}
|
|
</div>
|
|
</article>
|
|
)
|
|
}
|
|
|
|
function renderPageTurner() {
|
|
return (
|
|
<nav className="page-turner" aria-label="Page navigation">
|
|
<button type="button" onClick={() => shiftPage(-1)}>
|
|
Previous page
|
|
</button>
|
|
<div className="page-position">
|
|
<span>{pageRange}</span>
|
|
<div className="chapter-tabs" aria-label="Chapter selector">
|
|
{chapters.map((chapter) => (
|
|
<button
|
|
key={chapter}
|
|
type="button"
|
|
className={chapter === selectedVerse?.chapter ? 'active' : ''}
|
|
style={{ fontSize: `${Math.max(8, Math.min(11, 13 - chapters.length / 20))}px` }}
|
|
onClick={() => selectedVerse && jumpTo(selectedVerse.book, chapter, 1)}
|
|
>
|
|
{chapter}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<button type="button" onClick={() => shiftPage(1)}>
|
|
Next page
|
|
</button>
|
|
</nav>
|
|
)
|
|
}
|
|
|
|
function renderStudyMargin() {
|
|
if (!selectedVerse) return null
|
|
return (
|
|
<aside className="study-margin" aria-label="Study margin">
|
|
<div className="panel-header">
|
|
<h2>Study Margin</h2>
|
|
<span>
|
|
{activeSession?.strongsEnabled === false
|
|
? 'links off'
|
|
: `${selectedVerse.strongs.length} links`}
|
|
</span>
|
|
</div>
|
|
<p className="margin-reference">{reference(selectedVerse)}</p>
|
|
{activeSession?.strongsEnabled === false ? (
|
|
<p className="resource-disabled">Strong's links are disabled for this session.</p>
|
|
) : (
|
|
<div className="margin-resource-grid">
|
|
{renderOriginalLanguageLine()}
|
|
{renderLinkedEnglishLine()}
|
|
{showDefinitionPanel && selectedStrongEntry ? (
|
|
<article className="strong-focus" aria-label="Strong's definition">
|
|
<header
|
|
className="margin-section-header"
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={() => {
|
|
setDefinitionCollapsed((current) => !current)
|
|
recordDisplaySetting('Toggled Strong definition accordion')
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault()
|
|
setDefinitionCollapsed((current) => !current)
|
|
recordDisplaySetting('Toggled Strong definition accordion')
|
|
}
|
|
}}
|
|
>
|
|
<button
|
|
type="button"
|
|
className="collapse-button"
|
|
>
|
|
Definition
|
|
</button>
|
|
<span>{selectedStrongEntry.language}</span>
|
|
</header>
|
|
{!definitionCollapsed ? (
|
|
<>
|
|
<div>
|
|
<strong>{selectedStrongEntry.strong}</strong>
|
|
<span>{selectedStrongEntry.language}</span>
|
|
</div>
|
|
<h4>{selectedStrongEntry.lemma}</h4>
|
|
<p className="translit">{selectedStrongEntry.transliteration}</p>
|
|
<p>{selectedStrongEntry.definition}</p>
|
|
<button type="button" onClick={() => searchStrong(selectedStrongEntry.strong)}>
|
|
Show matching verses
|
|
</button>
|
|
</>
|
|
) : null}
|
|
</article>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
<div className="margin-resource-grid">
|
|
{renderStudyNotesSection()}
|
|
{renderAudioSection()}
|
|
{renderMapSection()}
|
|
{renderCommentarySection()}
|
|
{renderDictionarySection()}
|
|
</div>
|
|
</aside>
|
|
)
|
|
}
|
|
|
|
function renderFootnoteStrip() {
|
|
if (activeSession?.footnotesEnabled === false) {
|
|
return (
|
|
<section className="footnote-strip">
|
|
<strong>Footnotes</strong>
|
|
<span>Footnotes are disabled for this session.</span>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
const visibleFootnotes = selectedFootnotes.length ? selectedFootnotes : pageFootnotes.slice(0, 4)
|
|
return (
|
|
<section className="footnote-strip">
|
|
<strong>Footnotes</strong>
|
|
{visibleFootnotes.length ? (
|
|
<div className="footnote-list">
|
|
{visibleFootnotes.map((note) => (
|
|
<p
|
|
key={note.id}
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={() => recordFootnote(note.reference, note.text)}
|
|
draggable
|
|
onDragStart={(event) => startTextDrag(event, `${note.reference} ${note.text}`)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault()
|
|
recordFootnote(note.reference, note.text)
|
|
}
|
|
}}
|
|
>
|
|
<span>{note.reference}</span>
|
|
{note.text}
|
|
</p>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<span>No footnotes for the selected verse.</span>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function renderBookTabs() {
|
|
if (!selectedVerse) return null
|
|
|
|
return (
|
|
<nav className={`book-tabs ${pageMotion}`} aria-label="Bible book tabs">
|
|
{visibleBookTabs.map((book) => (
|
|
<button
|
|
key={book}
|
|
type="button"
|
|
className={book === selectedVerse.book ? 'active' : ''}
|
|
title={book}
|
|
onClick={() => jumpTo(book, 1, 1)}
|
|
>
|
|
{abbreviateBook(book)}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
)
|
|
}
|
|
|
|
function renderHostedLogin() {
|
|
return (
|
|
<main className={`login-shell ${themeMode === 'dark' ? 'theme-dark' : 'theme-light'}`}>
|
|
<section className="login-panel" aria-label="Libre Study sign in">
|
|
<div>
|
|
<span className="brand-mark">LS</span>
|
|
<span className="eyebrow">Hosted Workspace</span>
|
|
<h1>Libre Study</h1>
|
|
<p>Sign in to load your private sessions, notes, settings, and study timeline.</p>
|
|
</div>
|
|
<form onSubmit={submitHostedLogin}>
|
|
<label>
|
|
Username
|
|
<input
|
|
autoComplete="username"
|
|
value={loginUsername}
|
|
onChange={(event) => setLoginUsername(event.target.value)}
|
|
/>
|
|
</label>
|
|
<label>
|
|
Password
|
|
<input
|
|
autoComplete="current-password"
|
|
type="password"
|
|
value={loginPassword}
|
|
onChange={(event) => setLoginPassword(event.target.value)}
|
|
/>
|
|
</label>
|
|
{loginError ? <p className="login-error">{loginError}</p> : null}
|
|
<button type="submit" disabled={loginBusy || !loginUsername.trim() || !loginPassword}>
|
|
{loginBusy ? 'Signing in...' : 'Sign in'}
|
|
</button>
|
|
</form>
|
|
</section>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
function renderTimelineModal() {
|
|
if (!timelineModalOpen) return null
|
|
|
|
return (
|
|
<div className="modal-backdrop" role="presentation" onMouseDown={() => setTimelineModalOpen(false)}>
|
|
<section
|
|
className="modal-panel timeline-modal"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="Study timeline"
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
>
|
|
<header>
|
|
<div>
|
|
<span className="eyebrow">Timeline</span>
|
|
<h2>{activeSession?.title ?? 'Study Session'}</h2>
|
|
</div>
|
|
<button type="button" className="close-button" onClick={() => setTimelineModalOpen(false)}>
|
|
Close
|
|
</button>
|
|
</header>
|
|
{activeSession?.timeline.length ? (
|
|
<div className="timeline-canvas">
|
|
<ol>
|
|
{[...activeSession.timeline].reverse().map((entry) => (
|
|
<li key={entry.id}>
|
|
<span className="timeline-dot" />
|
|
<button
|
|
type="button"
|
|
disabled={!entry.reference}
|
|
onClick={() => {
|
|
restoreTimelineEntry(entry)
|
|
setTimelineModalOpen(false)
|
|
}}
|
|
>
|
|
<time>{formatTimelineTime(entry.at)}</time>
|
|
<strong>{entry.label}</strong>
|
|
{entry.reference ? <span>{entry.reference}</span> : null}
|
|
{entry.detail ? <small>{entry.detail}</small> : null}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
</div>
|
|
) : (
|
|
<div className="empty-timeline">
|
|
<span className="note-helper-figure" aria-hidden="true">
|
|
<span className="helper-head" />
|
|
<span className="helper-body" />
|
|
<span className="helper-notebook" />
|
|
<span className="helper-pencil" />
|
|
</span>
|
|
<p>This session has not recorded timeline events yet.</p>
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function renderSessionModal() {
|
|
if (!sessionModalOpen) return null
|
|
|
|
return (
|
|
<div className="modal-backdrop" role="presentation" onMouseDown={() => setSessionModalOpen(false)}>
|
|
<section
|
|
className="modal-panel session-modal"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="Session management"
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
>
|
|
<header>
|
|
<div>
|
|
<span className="eyebrow">Session</span>
|
|
<h2>Manage Study Session</h2>
|
|
</div>
|
|
<button type="button" className="close-button" onClick={() => setSessionModalOpen(false)}>
|
|
Close
|
|
</button>
|
|
</header>
|
|
|
|
<div className="modal-grid">
|
|
<section className="modal-section">
|
|
<h3>Current Session</h3>
|
|
<label>
|
|
Active session
|
|
<select value={activeSessionId} onChange={(event) => setActiveSessionId(event.target.value)}>
|
|
{sessions.map((session) => (
|
|
<option key={session.id} value={session.id}>
|
|
{session.title}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Session title
|
|
<input
|
|
value={activeSession?.title ?? ''}
|
|
onChange={(event) => renameActiveSession(event.target.value)}
|
|
/>
|
|
</label>
|
|
<dl className="session-facts">
|
|
<div>
|
|
<dt>Translation</dt>
|
|
<dd>{activeTranslation?.abbreviation ?? activeSession?.baseTranslation ?? 'n/a'}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Current page</dt>
|
|
<dd>{pageRange}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Timeline</dt>
|
|
<dd>{activeSession?.timeline.length ?? 0} events</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Started</dt>
|
|
<dd>{activeSession ? new Date(activeSession.createdAt).toLocaleString() : 'Not started'}</dd>
|
|
</div>
|
|
</dl>
|
|
<div className="resource-toggles">
|
|
<label>
|
|
<input
|
|
type="checkbox"
|
|
checked={activeSession?.strongsEnabled ?? true}
|
|
onChange={(event) => setActiveSessionOption({ strongsEnabled: event.target.checked })}
|
|
/>
|
|
Strong's links
|
|
</label>
|
|
<label>
|
|
<input
|
|
type="checkbox"
|
|
checked={activeSession?.footnotesEnabled ?? true}
|
|
onChange={(event) => setActiveSessionOption({ footnotesEnabled: event.target.checked })}
|
|
/>
|
|
Footnotes
|
|
</label>
|
|
<label>
|
|
<input
|
|
type="checkbox"
|
|
checked={activeSession?.commentaryEnabled ?? true}
|
|
onChange={(event) => setActiveSessionOption({ commentaryEnabled: event.target.checked })}
|
|
/>
|
|
Commentary
|
|
</label>
|
|
<label>
|
|
<input
|
|
type="checkbox"
|
|
checked={activeSession?.dictionaryEnabled ?? true}
|
|
onChange={(event) => setActiveSessionOption({ dictionaryEnabled: event.target.checked })}
|
|
/>
|
|
Dictionary
|
|
</label>
|
|
<label>
|
|
<input
|
|
type="checkbox"
|
|
checked={activeSession?.timelineEnabled ?? true}
|
|
onChange={(event) => setActiveSessionOption({ timelineEnabled: event.target.checked })}
|
|
/>
|
|
Record timeline
|
|
</label>
|
|
</div>
|
|
<button type="button" onClick={exportActiveSession}>
|
|
Export session
|
|
</button>
|
|
</section>
|
|
|
|
<section className="modal-section">
|
|
<h3>New Session</h3>
|
|
<label>
|
|
Session title
|
|
<input value={newSessionTitle} onChange={(event) => setNewSessionTitle(event.target.value)} />
|
|
</label>
|
|
<label>
|
|
Base translation
|
|
<select
|
|
value={newSessionBaseTranslation}
|
|
onChange={(event) => setNewSessionBaseTranslation(event.target.value)}
|
|
>
|
|
{translations.map((translation) => (
|
|
<option key={translation.id} value={translation.id}>
|
|
{translation.shortTitle}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<div className="resource-toggles">
|
|
<label>
|
|
<input
|
|
type="checkbox"
|
|
checked={newSessionStrongs}
|
|
onChange={(event) => setNewSessionStrongs(event.target.checked)}
|
|
/>
|
|
Include Strong's links
|
|
</label>
|
|
<label>
|
|
<input
|
|
type="checkbox"
|
|
checked={newSessionFootnotes}
|
|
onChange={(event) => setNewSessionFootnotes(event.target.checked)}
|
|
/>
|
|
Include footnotes
|
|
</label>
|
|
<label>
|
|
<input
|
|
type="checkbox"
|
|
checked={newSessionCommentary}
|
|
onChange={(event) => setNewSessionCommentary(event.target.checked)}
|
|
/>
|
|
Include commentary
|
|
</label>
|
|
<label>
|
|
<input
|
|
type="checkbox"
|
|
checked={newSessionDictionary}
|
|
onChange={(event) => setNewSessionDictionary(event.target.checked)}
|
|
/>
|
|
Include dictionary
|
|
</label>
|
|
<label>
|
|
<input
|
|
type="checkbox"
|
|
checked={newSessionTimeline}
|
|
onChange={(event) => setNewSessionTimeline(event.target.checked)}
|
|
/>
|
|
Record timeline
|
|
</label>
|
|
</div>
|
|
<button type="button" onClick={startNewSession}>
|
|
Create session
|
|
</button>
|
|
</section>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function renderAboutModal() {
|
|
if (!aboutModalOpen) return null
|
|
|
|
return (
|
|
<div className="modal-backdrop" role="presentation" onMouseDown={() => setAboutModalOpen(false)}>
|
|
<section
|
|
className="modal-panel about-modal"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="About Libre Study"
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
>
|
|
<header>
|
|
<div>
|
|
<span className="eyebrow">About</span>
|
|
<h2>Libre Study</h2>
|
|
</div>
|
|
<button type="button" className="close-button" onClick={() => setAboutModalOpen(false)}>
|
|
Close
|
|
</button>
|
|
</header>
|
|
<p>
|
|
Libre Study is a free Bible study application built by Christ IT. The project
|
|
started in July 2026 as an open study tool focused on readable Bible pages,
|
|
linked study data, notes, and reusable public Bible packages.
|
|
</p>
|
|
<dl className="about-list">
|
|
<div>
|
|
<dt>Project</dt>
|
|
<dd>Christ IT / Libre Study</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Current Bible package</dt>
|
|
<dd>
|
|
{activeTranslation
|
|
? `${activeTranslation.title}. ${activeTranslation.licenseName}`
|
|
: 'LibreBible translation package loaded from the local catalog.'}
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Software stack</dt>
|
|
<dd>Tauri, TypeScript, React, SQLite, Rust, Vite.</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Source data catalog</dt>
|
|
<dd>LibreBible packages are maintained separately so this app and future sites can consume the same resources.</dd>
|
|
</div>
|
|
</dl>
|
|
<div className="about-links">
|
|
<a href="https://christit.com">ChristIT.com</a>
|
|
<a href="https://git.christit.com/libre-study/libre-study">Libre Study Git</a>
|
|
<a href="https://git.christit.com/libre-study/libre-bible-data">LibreBible Data Git</a>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function renderSettingsModal() {
|
|
if (!settingsModalOpen) return null
|
|
|
|
return (
|
|
<div className="modal-backdrop" role="presentation" onMouseDown={() => setSettingsModalOpen(false)}>
|
|
<section
|
|
className="modal-panel settings-modal"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="Study display settings"
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
>
|
|
<header>
|
|
<div>
|
|
<span className="eyebrow">Settings</span>
|
|
<h2>Study Display</h2>
|
|
</div>
|
|
<button type="button" className="close-button" onClick={() => setSettingsModalOpen(false)}>
|
|
Close
|
|
</button>
|
|
</header>
|
|
<div className="settings-grid">
|
|
<section className="modal-section">
|
|
<h3>Theme</h3>
|
|
<div className="segmented-control" aria-label="Theme mode">
|
|
<button
|
|
type="button"
|
|
className={themeMode === 'light' ? 'active' : ''}
|
|
onClick={() => changeThemeMode('light')}
|
|
>
|
|
Light
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={themeMode === 'dark' ? 'active' : ''}
|
|
onClick={() => changeThemeMode('dark')}
|
|
>
|
|
Dark
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="modal-section">
|
|
<h3>Original Language Line</h3>
|
|
<label>
|
|
Text size
|
|
<input
|
|
type="range"
|
|
min="13"
|
|
max="26"
|
|
value={lemmaFontSize}
|
|
onChange={(event) => setLemmaFontSize(Number(event.target.value))}
|
|
/>
|
|
<span>{lemmaFontSize}px</span>
|
|
</label>
|
|
<label className="checkbox-row">
|
|
<input
|
|
type="checkbox"
|
|
checked={showOriginalLine}
|
|
onChange={(event) => {
|
|
setShowOriginalLine(event.target.checked)
|
|
recordDisplaySetting(
|
|
event.target.checked
|
|
? 'Show original language line'
|
|
: 'Hide original language line',
|
|
)
|
|
}}
|
|
/>
|
|
Show original language line
|
|
</label>
|
|
</section>
|
|
|
|
<section className="modal-section">
|
|
<h3>English Strong's Line</h3>
|
|
<label>
|
|
Text size
|
|
<input
|
|
type="range"
|
|
min="12"
|
|
max="23"
|
|
value={linkedFontSize}
|
|
onChange={(event) => setLinkedFontSize(Number(event.target.value))}
|
|
/>
|
|
<span>{linkedFontSize}px</span>
|
|
</label>
|
|
<label className="checkbox-row">
|
|
<input
|
|
type="checkbox"
|
|
checked={showLinkedLine}
|
|
onChange={(event) => {
|
|
setShowLinkedLine(event.target.checked)
|
|
recordDisplaySetting(
|
|
event.target.checked ? 'Show English linked text' : 'Hide English linked text',
|
|
)
|
|
}}
|
|
/>
|
|
Show English linked text
|
|
</label>
|
|
</section>
|
|
|
|
<section className="modal-section">
|
|
<h3>Definition Panel</h3>
|
|
<label className="checkbox-row">
|
|
<input
|
|
type="checkbox"
|
|
checked={showDefinitionPanel}
|
|
onChange={(event) => {
|
|
setShowDefinitionPanel(event.target.checked)
|
|
recordDisplaySetting(
|
|
event.target.checked ? 'Show Strong definition' : 'Hide Strong definition',
|
|
)
|
|
}}
|
|
/>
|
|
Show expanded definition
|
|
</label>
|
|
</section>
|
|
|
|
<section className="modal-section">
|
|
<h3>Reference Panels</h3>
|
|
<label className="checkbox-row">
|
|
<input
|
|
type="checkbox"
|
|
checked={showCommentaryPanel}
|
|
onChange={(event) => {
|
|
setShowCommentaryPanel(event.target.checked)
|
|
recordDisplaySetting(
|
|
event.target.checked ? 'Show commentary panel' : 'Hide commentary panel',
|
|
)
|
|
}}
|
|
/>
|
|
Show commentary
|
|
</label>
|
|
<label className="checkbox-row">
|
|
<input
|
|
type="checkbox"
|
|
checked={showDictionaryPanel}
|
|
onChange={(event) => {
|
|
setShowDictionaryPanel(event.target.checked)
|
|
recordDisplaySetting(
|
|
event.target.checked ? 'Show dictionary panel' : 'Hide dictionary panel',
|
|
)
|
|
}}
|
|
/>
|
|
Show dictionary
|
|
</label>
|
|
</section>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function renderNotesDrawer() {
|
|
if (!selectedVerse) return null
|
|
|
|
return (
|
|
<aside
|
|
className={[
|
|
'notes-drawer',
|
|
notesDrawerOpen || notesPinned ? 'open' : '',
|
|
notesPinned ? 'pinned' : '',
|
|
].filter(Boolean).join(' ')}
|
|
aria-label="Notes drawer"
|
|
onDragEnter={(event) => {
|
|
event.preventDefault()
|
|
scheduleNotesDragOpen()
|
|
}}
|
|
onDragLeave={clearNotesDragOpen}
|
|
onDragOver={(event) => event.preventDefault()}
|
|
onDrop={(event) => {
|
|
event.preventDefault()
|
|
clearNotesDragOpen()
|
|
appendNoteClip(event.dataTransfer.getData('text/plain'))
|
|
setNotesDrawerOpen(true)
|
|
}}
|
|
>
|
|
<button
|
|
type="button"
|
|
className="notes-drawer-grip"
|
|
aria-label={notesDrawerOpen || notesPinned ? 'Close notes drawer' : 'Open notes drawer'}
|
|
onClick={() => {
|
|
if (notesPinned) return
|
|
setNotesDrawerOpen((current) => !current)
|
|
}}
|
|
>
|
|
<span>N</span>
|
|
<span>O</span>
|
|
<span>T</span>
|
|
<span>E</span>
|
|
<span>S</span>
|
|
</button>
|
|
<div className="notes-workspace">
|
|
<header>
|
|
<div>
|
|
<span className="eyebrow">Notes</span>
|
|
<h2>Study Notes</h2>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="pin-notes"
|
|
onClick={() => {
|
|
const nextPinned = !notesPinned
|
|
setNotesPinned(nextPinned)
|
|
setNotesDrawerOpen(nextPinned)
|
|
recordDisplaySetting(nextPinned ? 'Pinned notes drawer' : 'Unpinned notes drawer')
|
|
}}
|
|
>
|
|
{notesPinned ? 'Unpin' : 'Pin'}
|
|
</button>
|
|
</header>
|
|
<textarea
|
|
value={noteDraft}
|
|
onChange={(event) => updateNoteDraft(event.target.value)}
|
|
placeholder="Write sermon notes, study observations, or drag clippings here later."
|
|
/>
|
|
<div className="tag-editor">
|
|
<input
|
|
value={tagInput}
|
|
onChange={(event) => setTagInput(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter') {
|
|
addTag().catch((reason) => setError(String(reason)))
|
|
}
|
|
}}
|
|
placeholder="prophecy, sermon, doctrine"
|
|
/>
|
|
<button type="button" onClick={() => addTag().catch((reason) => setError(String(reason)))}>
|
|
Tag verse
|
|
</button>
|
|
</div>
|
|
<div className="reader-tags">
|
|
{selectedVerse.tags.map((tag) => (
|
|
<span key={tag.id} style={{ backgroundColor: tag.color }}>
|
|
{tag.label}
|
|
</span>
|
|
))}
|
|
</div>
|
|
<div className="notes-quick-panels">
|
|
<button
|
|
type="button"
|
|
className="note-helper"
|
|
onClick={() => setTimelineModalOpen(true)}
|
|
aria-label="Open study timeline"
|
|
>
|
|
<span className="note-helper-figure" aria-hidden="true">
|
|
<span className="helper-head" />
|
|
<span className="helper-body" />
|
|
<span className="helper-notebook" />
|
|
<span className="helper-pencil" />
|
|
</span>
|
|
<span>
|
|
<strong>Study timeline</strong>
|
|
<small>{activeSession?.timeline.length ?? 0} events recorded</small>
|
|
</span>
|
|
</button>
|
|
<button type="button" className="note-placeholder">
|
|
<strong>Study tools</strong>
|
|
<small>Placeholder</small>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
)
|
|
}
|
|
|
|
const splitTemplate = `${splitPercent}fr 6px ${100 - splitPercent}fr`
|
|
|
|
if (authRequired && getAuthClient().enabled) {
|
|
return renderHostedLogin()
|
|
}
|
|
|
|
return (
|
|
<main
|
|
className={[
|
|
'app-shell',
|
|
railOpen ? 'menu-open' : '',
|
|
notesPinned ? 'notes-pinned' : '',
|
|
themeMode === 'dark' ? 'theme-dark' : 'theme-light',
|
|
].filter(Boolean).join(' ')}
|
|
>
|
|
<aside
|
|
className="tool-rail"
|
|
aria-label="Main tools"
|
|
onMouseEnter={() => setRailOpen(true)}
|
|
onMouseLeave={() => setRailOpen(false)}
|
|
onFocus={() => setRailOpen(true)}
|
|
onBlur={(event) => {
|
|
if (!event.currentTarget.contains(event.relatedTarget)) setRailOpen(false)
|
|
}}
|
|
>
|
|
<button type="button" className="menu-button" title="Menu" onClick={() => setRailOpen((current) => !current)}>
|
|
<span />
|
|
<span />
|
|
<span />
|
|
</button>
|
|
<div className="app-identity" aria-label="Libre Study">
|
|
<span className="brand-mark">LS</span>
|
|
<span className="rail-copy">
|
|
<strong>Libre Study</strong>
|
|
<small>Free Bible study by Christ IT</small>
|
|
</span>
|
|
</div>
|
|
<p className="rail-description">
|
|
A readable Bible page, study margin, notes workspace, and linked public Bible data.
|
|
</p>
|
|
{hostedUser ? (
|
|
<button
|
|
type="button"
|
|
title="Sign out"
|
|
onClick={() => signOutHostedWorkspace().catch((reason) => setError(String(reason)))}
|
|
>
|
|
<span className="rail-icon">U</span>
|
|
<span className="rail-copy"><strong>{hostedUser.username}</strong><small>Sign out</small></span>
|
|
</button>
|
|
) : null}
|
|
<button type="button" title="Session management" onClick={() => setSessionModalOpen(true)}>
|
|
<span className="rail-icon">S</span>
|
|
<span className="rail-copy"><strong>Sessions</strong><small>Save, export, and configure</small></span>
|
|
</button>
|
|
<button type="button" title="Settings" onClick={() => setSettingsModalOpen(true)}>
|
|
<span className="rail-icon">A</span>
|
|
<span className="rail-copy"><strong>Settings</strong><small>Study display controls</small></span>
|
|
</button>
|
|
<button type="button" className="rail-bottom" title="About Libre Study" onClick={() => setAboutModalOpen(true)}>
|
|
<span className="rail-icon">?</span>
|
|
<span className="rail-copy"><strong>About</strong><small>Project, licenses, and links</small></span>
|
|
</button>
|
|
</aside>
|
|
|
|
<section className="reader" ref={readerRef}>
|
|
{loading ? (
|
|
<div className="status-panel">Loading local library...</div>
|
|
) : error ? (
|
|
<div className="status-panel error">{error}</div>
|
|
) : selectedVerse ? (
|
|
<>
|
|
<header
|
|
className="top-bar"
|
|
style={{ gridTemplateColumns: splitTemplate }}
|
|
>
|
|
<div className="bible-top-tools">
|
|
<div className="page-controls" aria-label="Page selectors">
|
|
<label>
|
|
Translation
|
|
<select
|
|
value={activeSession?.baseTranslation ?? defaultTranslationId}
|
|
onChange={(event) =>
|
|
changeTranslation(event.target.value).catch((reason) => setError(String(reason)))
|
|
}
|
|
>
|
|
{translations.map((translation) => (
|
|
<option key={translation.id} value={translation.id}>
|
|
{translation.abbreviation}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Book
|
|
<select
|
|
value={selectedVerse.book}
|
|
onChange={(event) => jumpTo(event.target.value, 1, 1)}
|
|
>
|
|
{books.map((book) => (
|
|
<option key={book} value={book}>
|
|
{book}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Chapter
|
|
<select
|
|
value={selectedVerse.chapter}
|
|
onChange={(event) =>
|
|
jumpTo(selectedVerse.book, Number(event.target.value), 1)
|
|
}
|
|
>
|
|
{chapters.map((chapter) => (
|
|
<option key={chapter} value={chapter}>
|
|
{chapter}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Verse
|
|
<select
|
|
value={selectedVerse.verse}
|
|
onChange={(event) =>
|
|
jumpTo(
|
|
selectedVerse.book,
|
|
selectedVerse.chapter,
|
|
Number(event.target.value),
|
|
)
|
|
}
|
|
>
|
|
{verseNumbers.map((verse) => (
|
|
<option key={verse} value={verse}>
|
|
{verse}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Strong's
|
|
<select
|
|
value={strongDisplay}
|
|
onChange={(event) =>
|
|
setStrongDisplayMode(event.target.value as StrongDisplayMode)
|
|
}
|
|
>
|
|
<option value="off">Off</option>
|
|
<option value="highlight">Highlight</option>
|
|
<option value="numbers">Numbers</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<form
|
|
className="search-box"
|
|
onSubmit={(event) => {
|
|
event.preventDefault()
|
|
runSearch().catch((reason) => setError(String(reason)))
|
|
}}
|
|
>
|
|
<input
|
|
id="search"
|
|
value={query}
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
placeholder={`Search ${activeTranslation?.abbreviation ?? 'Bible'}, reference, or Strong's`}
|
|
/>
|
|
<button type="submit">Go</button>
|
|
</form>
|
|
</div>
|
|
<div className="split-spacer" aria-hidden="true" />
|
|
<div className="study-top-tools" aria-label="Study margin tools">
|
|
<button type="button" onClick={() => setSettingsModalOpen(true)}>
|
|
Margin settings
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div
|
|
className={`study-desk ${viewMode}`}
|
|
style={{ gridTemplateColumns: splitTemplate }}
|
|
>
|
|
{viewMode === 'compare' ? (
|
|
<div className="page-system compare-system" aria-label="Compare translations">
|
|
<div className="compare-pages">
|
|
{renderBiblePage()}
|
|
<article className="bible-page compare-placeholder" aria-label="Second translation placeholder">
|
|
<header>
|
|
<span>Second Translation</span>
|
|
<strong>Not loaded</strong>
|
|
</header>
|
|
<div>
|
|
<h2>Add another translation to compare side by side.</h2>
|
|
<p>
|
|
This pane is reserved for the next LibreBible translation package.
|
|
It will use the same book, chapter, and verse position as the KJV page.
|
|
</p>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className={`page-system ${viewMode} ${pageMotion}`}>
|
|
{renderBiblePage()}
|
|
{renderFootnoteStrip()}
|
|
{renderPageTurner()}
|
|
{renderBookTabs()}
|
|
</div>
|
|
<div
|
|
className="split-resizer"
|
|
role="separator"
|
|
aria-label="Resize Bible and study margin"
|
|
aria-orientation="vertical"
|
|
onMouseDown={beginSplitResize}
|
|
/>
|
|
{renderStudyMargin()}
|
|
</>
|
|
)}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="status-panel">No verses matched that search.</div>
|
|
)}
|
|
</section>
|
|
{renderSessionModal()}
|
|
{renderAboutModal()}
|
|
{renderSettingsModal()}
|
|
{renderTimelineModal()}
|
|
{renderNotesDrawer()}
|
|
</main>
|
|
)
|
|
}
|
|
|
|
export default App
|