Refine page reader controls and compare view

This commit is contained in:
2026-07-12 08:37:20 -05:00
parent b7e5ee9a93
commit e0b5ee9e58
8 changed files with 342 additions and 108 deletions
+224 -87
View File
@@ -4,14 +4,17 @@ import type { Verse } from './types'
import './App.css'
const tagColors = ['#2f6f5e', '#735c0f', '#8a3ffc', '#b42318', '#245ba7']
const pageSize = 34
type ViewMode = 'page' | 'study' | 'notes' | 'compare'
function reference(verse: Verse) {
return `${verse.book} ${verse.chapter}:${verse.verse}`
}
function App() {
const [viewMode, setViewMode] = useState<'page' | 'study' | 'notes'>('notes')
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 [selectedStrong, setSelectedStrong] = useState<string | null>(null)
@@ -23,6 +26,7 @@ function App() {
async function loadLibrary() {
setError(null)
const state = await getLibraryState()
setLibraryVerses(state.verses)
setVerses(state.verses)
setSelectedId((current) => current ?? state.verses[0]?.id ?? null)
}
@@ -43,6 +47,9 @@ function App() {
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)),
)
@@ -56,8 +63,11 @@ function App() {
}, [])
const selectedVerse = useMemo(
() => verses.find((verse) => verse.id === selectedId) ?? verses[0],
[selectedId, verses],
() =>
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
@@ -68,13 +78,37 @@ function App() {
}, [selectedStrong, selectedVerse])
const pageVerses = useMemo(() => {
if (!selectedVerse) return []
const selectedIndex = verses.findIndex((verse) => verse.id === selectedVerse.id)
const source = libraryVerses.length ? libraryVerses : verses
const selectedIndex = source.findIndex((verse) => verse.id === selectedVerse.id)
const start = Math.max(0, selectedIndex)
return verses.slice(start, start + 34)
}, [selectedVerse, verses])
return source.slice(start, start + pageSize)
}, [libraryVerses, 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)
@@ -82,6 +116,107 @@ function App() {
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)
}
function shiftPage(direction: -1 | 1) {
if (!selectedVerse) return
const source = libraryVerses.length ? libraryVerses : verses
const index = source.findIndex((verse) => verse.id === selectedVerse.id)
setSelectedId(
source[Math.min(source.length - 1, Math.max(0, index + direction * pageSize))]
?.id ?? selectedVerse.id,
)
}
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={() => setSelectedId(verse.id)}
>
<sup>{verse.verse}</sup>
{verse.text}
</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">
@@ -89,6 +224,7 @@ function App() {
<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">
@@ -103,6 +239,61 @@ function App() {
<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>
</div>
<form
className="search-box"
onSubmit={(event) => {
@@ -121,84 +312,35 @@ function App() {
</header>
<div className={`study-desk ${viewMode}`}>
<article className="bible-page" aria-label="Bible page">
<header>
<span>KJV</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={() => setSelectedId(verse.id)}
>
<sup>{verse.verse}</sup>
{verse.text}
</p>
))}
{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>
<footer>
<button
type="button"
onClick={() => {
const index = verses.findIndex((verse) => verse.id === selectedVerse.id)
setSelectedId(verses[Math.max(0, index - 34)]?.id ?? selectedVerse.id)
}}
>
Previous page
</button>
<span>{pageRange}</span>
<button
type="button"
onClick={() => {
const index = verses.findIndex((verse) => verse.id === selectedVerse.id)
setSelectedId(verses[Math.min(verses.length - 1, index + 34)]?.id ?? selectedVerse.id)
}}
>
Next page
</button>
</footer>
</article>
{viewMode !== 'page' ? (
<aside className="study-margin" aria-label="Study margin">
<div className="panel-header">
<h2>Study Margin</h2>
<span>{selectedVerse.strongs.length} links</span>
) : (
<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>
<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>
) : null}
)}
{viewMode === 'notes' ? (
<aside className="notes-workspace" aria-label="Notes workspace">
@@ -236,11 +378,6 @@ function App() {
</aside>
) : null}
</div>
<section className="footnote-strip">
<strong>Footnotes</strong>
<span>Dynamic translation notes and cross references will appear here.</span>
</section>
</>
) : (
<div className="status-panel">No verses matched that search.</div>