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
## 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
- 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",
"version": "0.1.3",
"version": "0.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "libre-study",
"version": "0.1.3",
"version": "0.1.4",
"dependencies": {
"@tauri-apps/api": "^2.11.1",
"react": "^19.2.7",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "libre-study",
"private": true,
"version": "0.1.3",
"version": "0.1.4",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
+1 -1
View File
@@ -77,7 +77,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
[[package]]
name = "app"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"log",
"rusqlite",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "app"
version = "0.1.3"
version = "0.1.4"
description = "A Tauri App"
authors = ["you"]
license = ""
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Libre Study",
"version": "0.1.3",
"version": "0.1.4",
"identifier": "org.librestudy.desktop",
"build": {
"frontendDist": "../dist",
+105 -15
View File
@@ -39,7 +39,7 @@
.reader {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
grid-template-rows: auto minmax(0, 1fr);
gap: 16px;
min-width: 0;
height: 100vh;
@@ -49,8 +49,8 @@
.top-bar {
display: grid;
grid-template-columns: minmax(260px, 1fr) minmax(320px, 520px);
gap: 18px;
grid-template-columns: minmax(260px, 0.8fr) minmax(420px, 1.1fr) minmax(300px, 0.65fr);
gap: 14px;
align-items: end;
}
@@ -83,6 +83,7 @@
}
input,
select,
textarea {
width: 100%;
min-width: 0;
@@ -116,35 +117,49 @@ button {
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: 2px solid #5d7f71;
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 {
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;
min-height: 0;
}
.study-desk.page {
grid-template-columns: minmax(680px, 980px);
.study-desk.page,
.study-desk.study,
.study-desk.compare {
grid-template-columns: minmax(680px, 1160px);
justify-content: center;
}
.study-desk.study {
grid-template-columns: minmax(560px, 1fr) minmax(260px, 360px);
}
.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,
.study-margin,
.page-system,
.notes-workspace,
.footnote-strip,
.status-panel {
border: 1px solid #d4cdbd;
border-radius: 8px;
@@ -152,6 +167,23 @@ textarea:focus-visible {
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 {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
@@ -222,6 +254,11 @@ textarea:focus-visible {
padding: 16px;
}
.study-margin {
border-left: 1px solid #e4dece;
background: #fbf8ef;
}
.panel-header {
display: flex;
justify-content: space-between;
@@ -347,9 +384,12 @@ textarea:focus-visible {
.footnote-strip {
display: flex;
grid-column: 1 / -1;
gap: 12px;
align-items: center;
min-height: 46px;
border-top: 1px solid #e4dece;
background: #fbf8ef;
}
.footnote-strip span {
@@ -361,11 +401,57 @@ textarea:focus-visible {
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) {
.study-desk {
.study-desk,
.study-desk.notes,
.study-desk.study,
.study-desk.compare {
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 {
height: auto;
min-height: 100vh;
@@ -390,6 +476,10 @@ textarea:focus-visible {
grid-template-columns: 1fr;
}
.page-controls {
grid-template-columns: 1fr 1fr;
}
.page-columns {
column-count: 1;
}
+204 -67
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,48 +116,38 @@ 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 (
<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>
</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>
<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}`}>
<article className="bible-page" aria-label="Bible page">
<article className="bible-page" aria-label={`${label} Bible page`}>
<header>
<span>KJV</span>
<span>{label}</span>
<strong>{reference(selectedVerse)}</strong>
<span>{verses.length} verses loaded</span>
</header>
@@ -140,29 +164,21 @@ function App() {
))}
</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)
}}
>
<button type="button" onClick={() => shiftPage(-1)}>
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)
}}
>
<button type="button" onClick={() => shiftPage(1)}>
Next page
</button>
</footer>
</article>
)
}
{viewMode !== 'page' ? (
function renderStudyMargin() {
if (!selectedVerse) return null
return (
<aside className="study-margin" aria-label="Study margin">
<div className="panel-header">
<h2>Study Margin</h2>
@@ -198,7 +214,133 @@ function App() {
</article>
) : null}
</aside>
) : null}
)
}
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>
</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}
<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">
@@ -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>