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
+7
View File
@@ -1,5 +1,12 @@
# Changelog # Changelog
## 0.1.4 - 2026-07-12
- Attached the study margin and footnote area to the Bible page surface.
- Added translation, book, chapter, and verse selectors above the page.
- Added a compare workspace placeholder for side-by-side translations once a second translation is loaded.
- Kept notes as a distinct workspace panel beside the page.
## 0.1.3 - 2026-07-12 ## 0.1.3 - 2026-07-12
- Phase 1 page-reader prototype: replaced the sidebar-first app with a Bible-page study desk. - Phase 1 page-reader prototype: replaced the sidebar-first app with a Bible-page study desk.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "libre-study", "name": "libre-study",
"version": "0.1.3", "version": "0.1.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "libre-study", "name": "libre-study",
"version": "0.1.3", "version": "0.1.4",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.11.1", "@tauri-apps/api": "^2.11.1",
"react": "^19.2.7", "react": "^19.2.7",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "libre-study", "name": "libre-study",
"private": true, "private": true,
"version": "0.1.3", "version": "0.1.4",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 127.0.0.1", "dev": "vite --host 127.0.0.1",
+1 -1
View File
@@ -77,7 +77,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
[[package]] [[package]]
name = "app" name = "app"
version = "0.1.3" version = "0.1.4"
dependencies = [ dependencies = [
"log", "log",
"rusqlite", "rusqlite",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "app" name = "app"
version = "0.1.3" version = "0.1.4"
description = "A Tauri App" description = "A Tauri App"
authors = ["you"] authors = ["you"]
license = "" license = ""
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Libre Study", "productName": "Libre Study",
"version": "0.1.3", "version": "0.1.4",
"identifier": "org.librestudy.desktop", "identifier": "org.librestudy.desktop",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
+105 -15
View File
@@ -39,7 +39,7 @@
.reader { .reader {
display: grid; display: grid;
grid-template-rows: auto minmax(0, 1fr) auto; grid-template-rows: auto minmax(0, 1fr);
gap: 16px; gap: 16px;
min-width: 0; min-width: 0;
height: 100vh; height: 100vh;
@@ -49,8 +49,8 @@
.top-bar { .top-bar {
display: grid; display: grid;
grid-template-columns: minmax(260px, 1fr) minmax(320px, 520px); grid-template-columns: minmax(260px, 0.8fr) minmax(420px, 1.1fr) minmax(300px, 0.65fr);
gap: 18px; gap: 14px;
align-items: end; align-items: end;
} }
@@ -83,6 +83,7 @@
} }
input, input,
select,
textarea { textarea {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
@@ -116,35 +117,49 @@ button {
button:focus-visible, button:focus-visible,
input:focus-visible, input:focus-visible,
select:focus-visible,
textarea:focus-visible { textarea:focus-visible {
outline: 2px solid #5d7f71; outline: 2px solid #5d7f71;
outline-offset: 2px; outline-offset: 2px;
} }
.page-controls {
display: grid;
grid-template-columns: minmax(130px, 1.1fr) minmax(130px, 1.3fr) minmax(86px, 0.7fr) minmax(78px, 0.6fr);
gap: 8px;
align-items: end;
}
.page-controls label {
display: grid;
gap: 4px;
color: #657061;
font-size: 11px;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
}
.study-desk { .study-desk {
display: grid; display: grid;
grid-template-columns: minmax(520px, 1.3fr) minmax(220px, 0.38fr) minmax(320px, 0.7fr); grid-template-columns: minmax(720px, 1fr);
gap: 16px; gap: 16px;
min-height: 0; min-height: 0;
} }
.study-desk.page { .study-desk.page,
grid-template-columns: minmax(680px, 980px); .study-desk.study,
.study-desk.compare {
grid-template-columns: minmax(680px, 1160px);
justify-content: center; justify-content: center;
} }
.study-desk.study {
grid-template-columns: minmax(560px, 1fr) minmax(260px, 360px);
}
.study-desk.notes { .study-desk.notes {
grid-template-columns: minmax(520px, 1.3fr) minmax(220px, 0.38fr) minmax(320px, 0.7fr); grid-template-columns: minmax(760px, 1.42fr) minmax(320px, 0.58fr);
} }
.bible-page, .page-system,
.study-margin,
.notes-workspace, .notes-workspace,
.footnote-strip,
.status-panel { .status-panel {
border: 1px solid #d4cdbd; border: 1px solid #d4cdbd;
border-radius: 8px; border-radius: 8px;
@@ -152,6 +167,23 @@ textarea:focus-visible {
box-shadow: 0 12px 30px rgba(44, 48, 42, 0.08); box-shadow: 0 12px 30px rgba(44, 48, 42, 0.08);
} }
.page-system {
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) auto;
min-height: 0;
overflow: hidden;
}
.page-system.study,
.page-system.notes {
grid-template-columns: minmax(0, 1fr) minmax(250px, 330px);
}
.page-system.compare-system {
display: block;
}
.bible-page { .bible-page {
display: grid; display: grid;
grid-template-rows: auto minmax(0, 1fr) auto; grid-template-rows: auto minmax(0, 1fr) auto;
@@ -222,6 +254,11 @@ textarea:focus-visible {
padding: 16px; padding: 16px;
} }
.study-margin {
border-left: 1px solid #e4dece;
background: #fbf8ef;
}
.panel-header { .panel-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -347,9 +384,12 @@ textarea:focus-visible {
.footnote-strip { .footnote-strip {
display: flex; display: flex;
grid-column: 1 / -1;
gap: 12px; gap: 12px;
align-items: center; align-items: center;
min-height: 46px; min-height: 46px;
border-top: 1px solid #e4dece;
background: #fbf8ef;
} }
.footnote-strip span { .footnote-strip span {
@@ -361,11 +401,57 @@ textarea:focus-visible {
color: #b42318; color: #b42318;
} }
.compare-pages {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
min-height: 0;
}
.compare-pages .bible-page + .bible-page {
border-left: 1px solid #e4dece;
}
.compare-placeholder {
align-content: start;
gap: 24px;
background: #fbf8ef;
}
.compare-placeholder h2 {
margin: 0 0 10px;
color: #252821;
font-size: 22px;
letter-spacing: 0;
}
.compare-placeholder p {
max-width: 36rem;
margin: 0;
color: #657061;
font-size: 15px;
line-height: 1.55;
}
@media (max-width: 1200px) { @media (max-width: 1200px) {
.study-desk { .study-desk,
.study-desk.notes,
.study-desk.study,
.study-desk.compare {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }
.page-system.study,
.page-system.notes,
.compare-pages {
grid-template-columns: minmax(0, 1fr);
}
.study-margin,
.compare-pages .bible-page + .bible-page {
border-top: 1px solid #e4dece;
border-left: 0;
}
.reader { .reader {
height: auto; height: auto;
min-height: 100vh; min-height: 100vh;
@@ -390,6 +476,10 @@ textarea:focus-visible {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.page-controls {
grid-template-columns: 1fr 1fr;
}
.page-columns { .page-columns {
column-count: 1; column-count: 1;
} }
+224 -87
View File
@@ -4,14 +4,17 @@ import type { Verse } from './types'
import './App.css' import './App.css'
const tagColors = ['#2f6f5e', '#735c0f', '#8a3ffc', '#b42318', '#245ba7'] const tagColors = ['#2f6f5e', '#735c0f', '#8a3ffc', '#b42318', '#245ba7']
const pageSize = 34
type ViewMode = 'page' | 'study' | 'notes' | 'compare'
function reference(verse: Verse) { function reference(verse: Verse) {
return `${verse.book} ${verse.chapter}:${verse.verse}` return `${verse.book} ${verse.chapter}:${verse.verse}`
} }
function App() { function App() {
const [viewMode, setViewMode] = useState<'page' | 'study' | 'notes'>('notes') const [viewMode, setViewMode] = useState<ViewMode>('notes')
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [libraryVerses, setLibraryVerses] = useState<Verse[]>([])
const [verses, setVerses] = useState<Verse[]>([]) const [verses, setVerses] = useState<Verse[]>([])
const [selectedId, setSelectedId] = useState<number | null>(null) const [selectedId, setSelectedId] = useState<number | null>(null)
const [selectedStrong, setSelectedStrong] = useState<string | null>(null) const [selectedStrong, setSelectedStrong] = useState<string | null>(null)
@@ -23,6 +26,7 @@ function App() {
async function loadLibrary() { async function loadLibrary() {
setError(null) setError(null)
const state = await getLibraryState() const state = await getLibraryState()
setLibraryVerses(state.verses)
setVerses(state.verses) setVerses(state.verses)
setSelectedId((current) => current ?? state.verses[0]?.id ?? null) setSelectedId((current) => current ?? state.verses[0]?.id ?? null)
} }
@@ -43,6 +47,9 @@ function App() {
const color = tagColors[Math.abs(label.length) % tagColors.length] const color = tagColors[Math.abs(label.length) % tagColors.length]
const updated = await addTagToVerse(selectedId, label, color) const updated = await addTagToVerse(selectedId, label, color)
setLibraryVerses((current) =>
current.map((verse) => (verse.id === updated.id ? updated : verse)),
)
setVerses((current) => setVerses((current) =>
current.map((verse) => (verse.id === updated.id ? updated : verse)), current.map((verse) => (verse.id === updated.id ? updated : verse)),
) )
@@ -56,8 +63,11 @@ function App() {
}, []) }, [])
const selectedVerse = useMemo( 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(() => { const selectedStrongEntry = useMemo(() => {
if (!selectedVerse?.strongs.length) return null if (!selectedVerse?.strongs.length) return null
@@ -68,13 +78,37 @@ function App() {
}, [selectedStrong, selectedVerse]) }, [selectedStrong, selectedVerse])
const pageVerses = useMemo(() => { const pageVerses = useMemo(() => {
if (!selectedVerse) return [] 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) const start = Math.max(0, selectedIndex)
return verses.slice(start, start + 34) return source.slice(start, start + pageSize)
}, [selectedVerse, verses]) }, [libraryVerses, selectedVerse, verses])
const pageRange = pageVerses.length const pageRange = pageVerses.length
? `${reference(pageVerses[0])} - ${reference(pageVerses[pageVerses.length - 1])}` ? `${reference(pageVerses[0])} - ${reference(pageVerses[pageVerses.length - 1])}`
: 'No page loaded' : '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) { function searchStrong(strong: string) {
setQuery(strong) setQuery(strong)
@@ -82,6 +116,107 @@ function App() {
runSearch(strong).catch((reason) => setError(String(reason))) 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 ( return (
<main className="app-shell"> <main className="app-shell">
<aside className="tool-rail" aria-label="Main tools"> <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="Bible page" onClick={() => setViewMode('page')}>B</button>
<button type="button" title="Study margin" onClick={() => setViewMode('study')}>M</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="Notes workspace" onClick={() => setViewMode('notes')}>N</button>
<button type="button" title="Compare translations" onClick={() => setViewMode('compare')}>C</button>
</aside> </aside>
<section className="reader"> <section className="reader">
@@ -103,6 +239,61 @@ function App() {
<span className="eyebrow">King James Version</span> <span className="eyebrow">King James Version</span>
<h1>{pageRange}</h1> <h1>{pageRange}</h1>
</div> </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 <form
className="search-box" className="search-box"
onSubmit={(event) => { onSubmit={(event) => {
@@ -121,84 +312,35 @@ function App() {
</header> </header>
<div className={`study-desk ${viewMode}`}> <div className={`study-desk ${viewMode}`}>
<article className="bible-page" aria-label="Bible page"> {viewMode === 'compare' ? (
<header> <div className="page-system compare-system" aria-label="Compare translations">
<span>KJV</span> <div className="compare-pages">
<strong>{reference(selectedVerse)}</strong> {renderBiblePage('KJV')}
<span>{verses.length} verses loaded</span> <article className="bible-page compare-placeholder" aria-label="Second translation placeholder">
</header> <header>
<div className="page-columns"> <span>Second Translation</span>
{pageVerses.map((verse) => ( <strong>Not loaded</strong>
<p </header>
key={verse.id} <div>
className={verse.id === selectedVerse.id ? 'page-verse selected' : 'page-verse'} <h2>Add another translation to compare side by side.</h2>
onClick={() => setSelectedId(verse.id)} <p>
> This pane is reserved for the next LibreBible translation package.
<sup>{verse.verse}</sup> It will use the same book, chapter, and verse position as the KJV page.
{verse.text} </p>
</p> </div>
))} </article>
</div>
</div> </div>
<footer> ) : (
<button <div className={`page-system ${viewMode}`}>
type="button" {renderBiblePage('KJV')}
onClick={() => { {viewMode !== 'page' ? renderStudyMargin() : null}
const index = verses.findIndex((verse) => verse.id === selectedVerse.id) <section className="footnote-strip">
setSelectedId(verses[Math.max(0, index - 34)]?.id ?? selectedVerse.id) <strong>Footnotes</strong>
}} <span>Dynamic translation notes and cross references will appear here.</span>
> </section>
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> </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' ? ( {viewMode === 'notes' ? (
<aside className="notes-workspace" aria-label="Notes workspace"> <aside className="notes-workspace" aria-label="Notes workspace">
@@ -236,11 +378,6 @@ function App() {
</aside> </aside>
) : null} ) : null}
</div> </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> <div className="status-panel">No verses matched that search.</div>