Add parallel translation stack book

This commit is contained in:
2026-07-15 19:25:28 -05:00
parent 003bbb5038
commit 7a56c7d205
4 changed files with 316 additions and 2 deletions
+1
View File
@@ -2,6 +2,7 @@
## Unreleased
- Added a Parallel study-stack book that follows the pinned verse, compares it against an alternate translation, and shows alternate translation notes with previous/next context.
- Made Bible book and chapter strips dynamically fit the available space with arrow paging while keeping the current book/chapter centered.
- Reordered the study margin around verse notes, lemma text, linked English text, definitions, and translation notes, with a settings control for changing that order.
- Moved audio controls into the study stack and enlarged/color-coded the stack book tabs.
+100 -1
View File
@@ -1120,6 +1120,90 @@ textarea:focus-visible {
white-space: nowrap;
}
.parallel-controls {
display: grid;
grid-template-columns: minmax(90px, 0.45fr) minmax(0, 1fr);
gap: 10px;
align-items: end;
}
.parallel-controls label {
display: grid;
gap: 5px;
color: #657061;
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.parallel-controls output {
min-height: 31px;
padding: 7px 8px;
border: 1px solid #ded6c7;
border-radius: 6px;
background: #fbf8ef;
color: #202124;
font-size: 12px;
box-sizing: border-box;
}
.parallel-current-verse {
padding: 14px;
border: 1px solid #ded6c7;
border-radius: 8px;
background: #fbf8ef;
}
.parallel-current-verse p:last-child {
color: #202124;
font-family: Georgia, 'Times New Roman', serif;
font-size: 20px;
line-height: 1.55;
}
.parallel-context,
.parallel-note-sections {
display: grid;
gap: 10px;
}
.parallel-context section,
.parallel-note-sections section {
display: grid;
gap: 5px;
padding: 10px;
border: 1px solid #eee7d8;
border-radius: 7px;
background: #fffdf6;
}
.parallel-context strong,
.parallel-note-sections h3 {
margin: 0;
color: #657061;
font-size: 12px;
font-weight: 900;
text-transform: uppercase;
}
.parallel-context p,
.parallel-note-sections p {
margin: 0;
color: #3f443d;
font-size: 13px;
line-height: 1.45;
}
.parallel-note-sections p strong {
display: block;
color: #735c0f;
font-size: 12px;
}
.parallel-note-sections p span {
display: block;
}
.notes-workspace {
display: flex;
flex-direction: column;
@@ -1213,6 +1297,10 @@ textarea:focus-visible {
background: #6e4f8f;
}
.study-stack-tabs .stack-tab-parallel {
background: #2d6f8f;
}
.study-stack-tabs .stack-tab-search {
background: #5b6570;
}
@@ -2046,6 +2134,10 @@ textarea:focus-visible {
.theme-dark .english-strong-line,
.theme-dark .resource-card,
.theme-dark .margin-order-list div,
.theme-dark .parallel-current-verse,
.theme-dark .parallel-context section,
.theme-dark .parallel-note-sections section,
.theme-dark .parallel-controls output,
.theme-dark .place-list section,
.theme-dark .route-list span,
.theme-dark .audio-controls .secondary-button {
@@ -2110,6 +2202,9 @@ textarea:focus-visible {
.theme-dark .audio-sliders label,
.theme-dark .audio-status,
.theme-dark .audio-status strong,
.theme-dark .parallel-controls label,
.theme-dark .parallel-context strong,
.theme-dark .parallel-note-sections h3,
.theme-dark .session-facts dt,
.theme-dark .about-list dt,
.theme-dark .modal-section label,
@@ -2133,7 +2228,11 @@ textarea:focus-visible {
.theme-dark .about-modal p,
.theme-dark .resource-toggles label,
.theme-dark .checkbox-row,
.theme-dark .segmented-control button {
.theme-dark .segmented-control button,
.theme-dark .parallel-current-verse p:last-child,
.theme-dark .parallel-context p,
.theme-dark .parallel-note-sections p,
.theme-dark .parallel-controls output {
color: #eee8d8 !important;
}
+212
View File
@@ -37,6 +37,7 @@ import type {
AudioBibleSegment,
CommentaryEntry,
DictionaryEntry,
Footnote,
MapContext,
TranslationResource,
Verse,
@@ -350,6 +351,7 @@ function App() {
const [marginColumnPercent, setMarginColumnPercent] = useState(20)
const [studyStackPinned, setStudyStackPinned] = useState(true)
const [activeStudyBook, setActiveStudyBook] = useState<StudyBook>('notebook')
const [parallelTranslationId, setParallelTranslationId] = useState<string | null>(null)
const [marginPanelOrder, setMarginPanelOrder] = useState<MarginPanelId[]>([
'margin-note',
'original',
@@ -359,6 +361,13 @@ function App() {
])
const [commentaryEntries, setCommentaryEntries] = useState<CommentaryEntry[]>([])
const [dictionaryEntries, setDictionaryEntries] = useState<DictionaryEntry[]>([])
const [parallelVerses, setParallelVerses] = useState<Verse[]>([])
const [parallelNotes, setParallelNotes] = useState<{ footnotes: Footnote[]; studyNotes: Footnote[] }>({
footnotes: [],
studyNotes: [],
})
const [parallelLoading, setParallelLoading] = useState(false)
const [parallelError, setParallelError] = useState<string | null>(null)
const [mapContext, setMapContext] = useState<MapContext>({
selectedPlaces: [],
pagePlaces: [],
@@ -418,6 +427,7 @@ function App() {
setMarginColumnPercent(settings.marginColumnPercent ?? 20)
setStudyStackPinned(settings.studyStackPinned ?? true)
setActiveStudyBook(settings.activeStudyBook ?? 'notebook')
setParallelTranslationId(settings.parallelTranslationId ?? null)
setMarginPanelOrder(settings.marginPanelOrder ?? ['margin-note', 'original', 'linked', 'definition', 'study-notes'])
setThemeMode(settings.themeMode ?? 'light')
}
@@ -572,6 +582,7 @@ function App() {
marginColumnPercent,
studyStackPinned,
activeStudyBook,
parallelTranslationId,
marginPanelOrder,
themeMode,
}),
@@ -590,6 +601,7 @@ function App() {
marginPanelOrder,
notesPinned,
originalCollapsed,
parallelTranslationId,
showCommentaryPanel,
showDefinitionPanel,
showDictionaryPanel,
@@ -715,6 +727,13 @@ function App() {
verses[0],
[hoveredId, libraryVerses, selectedId, verses],
)
const pinnedVerse = useMemo(
() =>
verses.find((verse) => verse.id === selectedId) ??
libraryVerses.find((verse) => verse.id === selectedId) ??
selectedVerse,
[libraryVerses, selectedId, selectedVerse, verses],
)
const selectedStrongEntry = useMemo(() => {
if (!selectedVerse?.strongs.length) return null
return (
@@ -732,6 +751,20 @@ function App() {
translations.find((translation) => translation.id === defaultTranslationId),
[activeSession?.baseTranslation, translations],
)
const availableParallelTranslations = useMemo(
() => translations.filter((translation) => translation.id !== activeSession?.baseTranslation),
[activeSession?.baseTranslation, translations],
)
const selectedParallelTranslationId = useMemo(() => {
if (parallelTranslationId && availableParallelTranslations.some((translation) => translation.id === parallelTranslationId)) {
return parallelTranslationId
}
return availableParallelTranslations[0]?.id ?? null
}, [availableParallelTranslations, parallelTranslationId])
const parallelTranslation = useMemo(
() => translations.find((translation) => translation.id === selectedParallelTranslationId) ?? null,
[selectedParallelTranslationId, translations],
)
useEffect(() => {
setBookTabOffset(0)
@@ -741,6 +774,70 @@ function App() {
setChapterTabOffset(0)
}, [selectedVerse?.book, selectedVerse?.chapter])
useEffect(() => {
if (!selectedParallelTranslationId || !pinnedVerse) {
setParallelVerses([])
setParallelNotes({ footnotes: [], studyNotes: [] })
setParallelError(null)
return
}
let cancelled = false
async function loadParallelVerse() {
setParallelLoading(true)
setParallelError(null)
try {
const state = await getLibraryState(selectedParallelTranslationId)
const currentIndex = state.verses.findIndex(
(verse) =>
verse.book === pinnedVerse.book &&
verse.chapter === pinnedVerse.chapter &&
verse.verse === pinnedVerse.verse,
)
if (currentIndex < 0) {
if (!cancelled) {
setParallelVerses([])
setParallelNotes({ footnotes: [], studyNotes: [] })
setParallelError(`No matching verse found in ${parallelTranslation?.abbreviation ?? 'parallel translation'}.`)
}
return
}
const currentVerse = state.verses[currentIndex]
const context = [
state.verses[currentIndex - 1],
currentVerse,
state.verses[currentIndex + 1],
].filter(
(verse): verse is Verse =>
Boolean(verse && verse.book === currentVerse.book),
)
const notesByVerse = await getTranslationNotesForVerses(selectedParallelTranslationId, [currentVerse])
const notes = notesByVerse.get(String(currentVerse.id)) ?? { footnotes: [], studyNotes: [] }
if (!cancelled) {
setParallelVerses(context)
setParallelNotes(notes)
}
} catch (reason) {
if (!cancelled) {
setParallelVerses([])
setParallelNotes({ footnotes: [], studyNotes: [] })
setParallelError(String(reason))
}
} finally {
if (!cancelled) setParallelLoading(false)
}
}
loadParallelVerse()
return () => {
cancelled = true
}
}, [parallelTranslation?.abbreviation, pinnedVerse, selectedParallelTranslationId])
useEffect(() => {
let cancelled = false
@@ -2294,6 +2391,118 @@ function App() {
)
}
function renderParallelBook() {
const currentParallelVerse = pinnedVerse
? parallelVerses.find(
(verse) =>
verse.book === pinnedVerse.book &&
verse.chapter === pinnedVerse.chapter &&
verse.verse === pinnedVerse.verse,
) ?? null
: null
return (
<section className="stack-book parallel-book article-book" aria-label="Parallel translation">
<header>
<div>
<span className="eyebrow">Parallel</span>
<h2>{pinnedVerse ? reference(pinnedVerse) : 'Parallel Translation'}</h2>
</div>
<span>{parallelLoading ? 'Loading' : parallelTranslation?.abbreviation ?? 'None'}</span>
</header>
<div className="parallel-controls">
<label>
Base
<output>{activeTranslation?.abbreviation ?? activeSession?.baseTranslation ?? 'Base'}</output>
</label>
<label>
Compare
<select
value={selectedParallelTranslationId ?? ''}
onChange={(event) => setParallelTranslationId(event.target.value || null)}
>
{availableParallelTranslations.length ? null : (
<option value="">No alternate translations</option>
)}
{availableParallelTranslations.map((translation) => (
<option key={translation.id} value={translation.id}>
{translation.abbreviation} - {translation.shortTitle}
</option>
))}
</select>
</label>
</div>
{parallelError ? (
<p className="resource-disabled">{parallelError}</p>
) : currentParallelVerse ? (
<>
<article className="parallel-current-verse">
<p className="article-kicker">
{parallelTranslation?.title ?? selectedParallelTranslationId}
</p>
<h3>{reference(currentParallelVerse)}</h3>
<p>{currentParallelVerse.text}</p>
</article>
<div className="parallel-context">
{parallelVerses
.filter((verse) => verse.id !== currentParallelVerse.id)
.map((verse) => (
<section key={verse.id}>
<strong>{reference(verse)}</strong>
<p>{verse.text}</p>
</section>
))}
</div>
<div className="parallel-note-sections">
<section>
<h3>Translation Notes</h3>
{parallelNotes.footnotes.length ? (
parallelNotes.footnotes.map((note) => (
<p
key={note.id}
draggable
onDragStart={(event) => startTextDrag(event, `${note.reference} ${note.text}`)}
>
<strong>{note.reference}</strong>
<span>{note.text}</span>
</p>
))
) : (
<p className="resource-disabled">No translation notes for this verse.</p>
)}
</section>
<section>
<h3>Study Notes</h3>
{parallelNotes.studyNotes.length ? (
parallelNotes.studyNotes.map((note) => (
<p
key={note.id}
draggable
onDragStart={(event) => startTextDrag(event, `${note.reference} ${note.text}`)}
>
<strong>{note.reference}</strong>
<span>{note.text}</span>
</p>
))
) : (
<p className="resource-disabled">No study notes for this verse.</p>
)}
</section>
</div>
<p className="map-attribution">
{parallelTranslation?.licenseName ?? 'Parallel translation package'}
{parallelTranslation?.attribution ? ` - ${parallelTranslation.attribution}` : ''}
</p>
</>
) : (
<p className="resource-disabled">
{parallelLoading ? 'Loading parallel verse...' : 'Choose an alternate translation to compare.'}
</p>
)}
</section>
)
}
function renderSearchBook() {
return (
<section className="stack-book search-book" aria-label="Search results">
@@ -2351,6 +2560,8 @@ function App() {
return renderCommentaryBook()
case 'audio':
return renderAudioBook()
case 'parallel':
return renderParallelBook()
case 'search':
return renderSearchBook()
case 'notebook':
@@ -2367,6 +2578,7 @@ function App() {
{ id: 'atlas', label: 'Atlas' },
{ id: 'commentary', label: 'Comm' },
{ id: 'audio', label: 'Audio' },
{ id: 'parallel', label: 'Para' },
{ id: 'search', label: 'Search' },
]
+3 -1
View File
@@ -88,11 +88,12 @@ export type StudySettings = {
marginColumnPercent: number
studyStackPinned: boolean
activeStudyBook: StudyBook
parallelTranslationId: string | null
marginPanelOrder: MarginPanelId[]
themeMode: ThemeMode
}
export type StudyBook = 'notebook' | 'dictionary' | 'lexicon' | 'atlas' | 'commentary' | 'audio' | 'search'
export type StudyBook = 'notebook' | 'dictionary' | 'lexicon' | 'atlas' | 'commentary' | 'audio' | 'parallel' | 'search'
export type MarginPanelId = 'margin-note' | 'original' | 'linked' | 'definition' | 'study-notes'
export const defaultMarginPanelOrder: MarginPanelId[] = [
@@ -161,6 +162,7 @@ export function defaultStudySettings(): StudySettings {
marginColumnPercent: 20,
studyStackPinned: true,
activeStudyBook: 'notebook',
parallelTranslationId: null,
marginPanelOrder: defaultMarginPanelOrder,
themeMode: 'light',
}