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 ## 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. - 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. - 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. - 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; 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 { .notes-workspace {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1213,6 +1297,10 @@ textarea:focus-visible {
background: #6e4f8f; background: #6e4f8f;
} }
.study-stack-tabs .stack-tab-parallel {
background: #2d6f8f;
}
.study-stack-tabs .stack-tab-search { .study-stack-tabs .stack-tab-search {
background: #5b6570; background: #5b6570;
} }
@@ -2046,6 +2134,10 @@ textarea:focus-visible {
.theme-dark .english-strong-line, .theme-dark .english-strong-line,
.theme-dark .resource-card, .theme-dark .resource-card,
.theme-dark .margin-order-list div, .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 .place-list section,
.theme-dark .route-list span, .theme-dark .route-list span,
.theme-dark .audio-controls .secondary-button { .theme-dark .audio-controls .secondary-button {
@@ -2110,6 +2202,9 @@ textarea:focus-visible {
.theme-dark .audio-sliders label, .theme-dark .audio-sliders label,
.theme-dark .audio-status, .theme-dark .audio-status,
.theme-dark .audio-status strong, .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 .session-facts dt,
.theme-dark .about-list dt, .theme-dark .about-list dt,
.theme-dark .modal-section label, .theme-dark .modal-section label,
@@ -2133,7 +2228,11 @@ textarea:focus-visible {
.theme-dark .about-modal p, .theme-dark .about-modal p,
.theme-dark .resource-toggles label, .theme-dark .resource-toggles label,
.theme-dark .checkbox-row, .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; color: #eee8d8 !important;
} }
+212
View File
@@ -37,6 +37,7 @@ import type {
AudioBibleSegment, AudioBibleSegment,
CommentaryEntry, CommentaryEntry,
DictionaryEntry, DictionaryEntry,
Footnote,
MapContext, MapContext,
TranslationResource, TranslationResource,
Verse, Verse,
@@ -350,6 +351,7 @@ function App() {
const [marginColumnPercent, setMarginColumnPercent] = useState(20) const [marginColumnPercent, setMarginColumnPercent] = useState(20)
const [studyStackPinned, setStudyStackPinned] = useState(true) const [studyStackPinned, setStudyStackPinned] = useState(true)
const [activeStudyBook, setActiveStudyBook] = useState<StudyBook>('notebook') const [activeStudyBook, setActiveStudyBook] = useState<StudyBook>('notebook')
const [parallelTranslationId, setParallelTranslationId] = useState<string | null>(null)
const [marginPanelOrder, setMarginPanelOrder] = useState<MarginPanelId[]>([ const [marginPanelOrder, setMarginPanelOrder] = useState<MarginPanelId[]>([
'margin-note', 'margin-note',
'original', 'original',
@@ -359,6 +361,13 @@ function App() {
]) ])
const [commentaryEntries, setCommentaryEntries] = useState<CommentaryEntry[]>([]) const [commentaryEntries, setCommentaryEntries] = useState<CommentaryEntry[]>([])
const [dictionaryEntries, setDictionaryEntries] = useState<DictionaryEntry[]>([]) 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>({ const [mapContext, setMapContext] = useState<MapContext>({
selectedPlaces: [], selectedPlaces: [],
pagePlaces: [], pagePlaces: [],
@@ -418,6 +427,7 @@ function App() {
setMarginColumnPercent(settings.marginColumnPercent ?? 20) setMarginColumnPercent(settings.marginColumnPercent ?? 20)
setStudyStackPinned(settings.studyStackPinned ?? true) setStudyStackPinned(settings.studyStackPinned ?? true)
setActiveStudyBook(settings.activeStudyBook ?? 'notebook') setActiveStudyBook(settings.activeStudyBook ?? 'notebook')
setParallelTranslationId(settings.parallelTranslationId ?? null)
setMarginPanelOrder(settings.marginPanelOrder ?? ['margin-note', 'original', 'linked', 'definition', 'study-notes']) setMarginPanelOrder(settings.marginPanelOrder ?? ['margin-note', 'original', 'linked', 'definition', 'study-notes'])
setThemeMode(settings.themeMode ?? 'light') setThemeMode(settings.themeMode ?? 'light')
} }
@@ -572,6 +582,7 @@ function App() {
marginColumnPercent, marginColumnPercent,
studyStackPinned, studyStackPinned,
activeStudyBook, activeStudyBook,
parallelTranslationId,
marginPanelOrder, marginPanelOrder,
themeMode, themeMode,
}), }),
@@ -590,6 +601,7 @@ function App() {
marginPanelOrder, marginPanelOrder,
notesPinned, notesPinned,
originalCollapsed, originalCollapsed,
parallelTranslationId,
showCommentaryPanel, showCommentaryPanel,
showDefinitionPanel, showDefinitionPanel,
showDictionaryPanel, showDictionaryPanel,
@@ -715,6 +727,13 @@ function App() {
verses[0], verses[0],
[hoveredId, libraryVerses, selectedId, verses], [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(() => { const selectedStrongEntry = useMemo(() => {
if (!selectedVerse?.strongs.length) return null if (!selectedVerse?.strongs.length) return null
return ( return (
@@ -732,6 +751,20 @@ function App() {
translations.find((translation) => translation.id === defaultTranslationId), translations.find((translation) => translation.id === defaultTranslationId),
[activeSession?.baseTranslation, translations], [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(() => { useEffect(() => {
setBookTabOffset(0) setBookTabOffset(0)
@@ -741,6 +774,70 @@ function App() {
setChapterTabOffset(0) setChapterTabOffset(0)
}, [selectedVerse?.book, selectedVerse?.chapter]) }, [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(() => { useEffect(() => {
let cancelled = false 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() { function renderSearchBook() {
return ( return (
<section className="stack-book search-book" aria-label="Search results"> <section className="stack-book search-book" aria-label="Search results">
@@ -2351,6 +2560,8 @@ function App() {
return renderCommentaryBook() return renderCommentaryBook()
case 'audio': case 'audio':
return renderAudioBook() return renderAudioBook()
case 'parallel':
return renderParallelBook()
case 'search': case 'search':
return renderSearchBook() return renderSearchBook()
case 'notebook': case 'notebook':
@@ -2367,6 +2578,7 @@ function App() {
{ id: 'atlas', label: 'Atlas' }, { id: 'atlas', label: 'Atlas' },
{ id: 'commentary', label: 'Comm' }, { id: 'commentary', label: 'Comm' },
{ id: 'audio', label: 'Audio' }, { id: 'audio', label: 'Audio' },
{ id: 'parallel', label: 'Para' },
{ id: 'search', label: 'Search' }, { id: 'search', label: 'Search' },
] ]
+3 -1
View File
@@ -88,11 +88,12 @@ export type StudySettings = {
marginColumnPercent: number marginColumnPercent: number
studyStackPinned: boolean studyStackPinned: boolean
activeStudyBook: StudyBook activeStudyBook: StudyBook
parallelTranslationId: string | null
marginPanelOrder: MarginPanelId[] marginPanelOrder: MarginPanelId[]
themeMode: ThemeMode 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 type MarginPanelId = 'margin-note' | 'original' | 'linked' | 'definition' | 'study-notes'
export const defaultMarginPanelOrder: MarginPanelId[] = [ export const defaultMarginPanelOrder: MarginPanelId[] = [
@@ -161,6 +162,7 @@ export function defaultStudySettings(): StudySettings {
marginColumnPercent: 20, marginColumnPercent: 20,
studyStackPinned: true, studyStackPinned: true,
activeStudyBook: 'notebook', activeStudyBook: 'notebook',
parallelTranslationId: null,
marginPanelOrder: defaultMarginPanelOrder, marginPanelOrder: defaultMarginPanelOrder,
themeMode: 'light', themeMode: 'light',
} }