Files
libre-study/src/App.tsx
T

576 lines
20 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 selectedFootnotes = selectedVerse?.footnotes ?? []
const pageFootnotes = useMemo(
() => pageVerses.flatMap((verse) => verse.footnotes ?? []),
[pageVerses],
)
const books = useMemo(
() => Array.from(new Set(libraryVerses.map((verse) => verse.book))),
[libraryVerses],
)
const 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 renderOriginalLanguageLine() {
if (!selectedVerse?.strongs.length) return null
return (
<section className="original-language-verse" aria-label="Original language lemma line">
<h3>Lemma Line</h3>
<p>
{selectedVerse.strongs.map((entry) => (
<button
key={`${entry.strong}-${entry.position}-lemma`}
type="button"
className={entry.strong === selectedStrongEntry?.strong ? 'lemma-token active' : 'lemma-token'}
title={`${entry.surface}: ${entry.strong}`}
onClick={() => setSelectedStrong(entry.strong)}
>
<span>{entry.lemma || entry.surface}</span>
<small>{entry.strong}</small>
</button>
))}
</p>
</section>
)
}
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>
{renderOriginalLanguageLine()}
<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>
)
}
function renderFootnoteStrip() {
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}>
<span>{note.reference}</span>
{note.text}
</p>
))}
</div>
) : (
<span>No footnotes for the selected verse.</span>
)}
</section>
)
}
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&apos;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}
{renderFootnoteStrip()}
</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