528 lines
19 KiB
TypeScript
528 lines
19 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
|
import { addTagToVerse, getLibraryState, searchVerses } from './studyClient'
|
|
import type { ReactNode } from 'react'
|
|
import type { Verse } from './types'
|
|
import './App.css'
|
|
|
|
const tagColors = ['#2f6f5e', '#735c0f', '#8a3ffc', '#b42318', '#245ba7']
|
|
const pageSize = 34
|
|
type ViewMode = 'page' | 'study' | 'notes' | 'compare'
|
|
type StrongDisplayMode = 'off' | 'highlight' | 'numbers'
|
|
|
|
function reference(verse: Verse) {
|
|
return `${verse.book} ${verse.chapter}:${verse.verse}`
|
|
}
|
|
|
|
function App() {
|
|
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 [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | 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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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(() => {
|
|
loadLibrary()
|
|
.catch((reason) => setError(String(reason)))
|
|
.finally(() => setLoading(false))
|
|
}, [])
|
|
|
|
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 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 books = useMemo(
|
|
() => Array.from(new Set(libraryVerses.map((verse) => verse.book))),
|
|
[libraryVerses],
|
|
)
|
|
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)
|
|
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
|
|
setQuery('')
|
|
setSelectedStrong(null)
|
|
setVerses(libraryVerses)
|
|
setSelectedId(next.id)
|
|
setPageStartId(findPageStartId(next))
|
|
}
|
|
|
|
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
|
|
setPageStartId(next.id)
|
|
setSelectedId(next.id)
|
|
setSelectedStrong(null)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
function selectStrong(verse: Verse, strong: string) {
|
|
setSelectedId(verse.id)
|
|
setSelectedStrong(strong)
|
|
}
|
|
|
|
function renderVerseText(verse: Verse) {
|
|
if (!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 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'}
|
|
onClick={() => selectVerse(verse)}
|
|
>
|
|
<sup>{verse.verse}</sup>
|
|
{renderVerseText(verse)}
|
|
</p>
|
|
))}
|
|
</div>
|
|
<footer>
|
|
<button type="button" onClick={() => shiftPage(-1)}>
|
|
Previous page
|
|
</button>
|
|
<span>{pageRange}</span>
|
|
<button type="button" onClick={() => shiftPage(1)}>
|
|
Next page
|
|
</button>
|
|
</footer>
|
|
</article>
|
|
)
|
|
}
|
|
|
|
function renderStudyMargin() {
|
|
if (!selectedVerse) return null
|
|
return (
|
|
<aside className="study-margin" aria-label="Study margin">
|
|
<div className="panel-header">
|
|
<h2>Study Margin</h2>
|
|
<span>{selectedVerse.strongs.length} links</span>
|
|
</div>
|
|
<p className="margin-reference">{reference(selectedVerse)}</p>
|
|
<div className="linked-word-list" aria-label="Linked Strong's words">
|
|
{selectedVerse.strongs.map((entry) => (
|
|
<button
|
|
key={`${entry.strong}-${entry.position}`}
|
|
className={entry.strong === selectedStrongEntry?.strong ? 'word-link active' : 'word-link'}
|
|
type="button"
|
|
title={`${entry.surface}: ${entry.strong}`}
|
|
onClick={() => setSelectedStrong(entry.strong)}
|
|
>
|
|
<span>{entry.surface}</span>
|
|
<small>{entry.strong}</small>
|
|
</button>
|
|
))}
|
|
</div>
|
|
{selectedStrongEntry ? (
|
|
<article className="strong-focus">
|
|
<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>
|
|
</article>
|
|
) : null}
|
|
</aside>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<main className="app-shell">
|
|
<aside className="tool-rail" aria-label="Main tools">
|
|
<span className="brand-mark">LS</span>
|
|
<button type="button" title="Bible page" onClick={() => setViewMode('page')}>B</button>
|
|
<button type="button" title="Study margin" onClick={() => setViewMode('study')}>M</button>
|
|
<button type="button" title="Notes workspace" onClick={() => setViewMode('notes')}>N</button>
|
|
<button type="button" title="Compare translations" onClick={() => setViewMode('compare')}>C</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="version-block">
|
|
<span className="eyebrow">King James Version</span>
|
|
<h1>{pageRange}</h1>
|
|
</div>
|
|
<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) =>
|
|
setStrongDisplay(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>
|
|
</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}`}>
|
|
{renderBiblePage('KJV')}
|
|
{viewMode !== 'page' ? renderStudyMargin() : null}
|
|
<section className="footnote-strip">
|
|
<strong>Footnotes</strong>
|
|
<span>Dynamic translation notes and cross references will appear here.</span>
|
|
</section>
|
|
</div>
|
|
)}
|
|
|
|
{viewMode === 'notes' ? (
|
|
<aside className="notes-workspace" aria-label="Notes workspace">
|
|
<header>
|
|
<span className="eyebrow">Notes</span>
|
|
<h2>Study Notes</h2>
|
|
</header>
|
|
<textarea
|
|
value={noteDraft}
|
|
onChange={(event) => setNoteDraft(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>
|
|
</aside>
|
|
) : null}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="status-panel">No verses matched that search.</div>
|
|
)}
|
|
</section>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
export default App
|