From 7a56c7d205b4d7304d081c3d7978d7c2a31c8bfa Mon Sep 17 00:00:00 2001 From: Jason Date: Wed, 15 Jul 2026 19:25:28 -0500 Subject: [PATCH] Add parallel translation stack book --- CHANGELOG.md | 1 + src/App.css | 101 +++++++++++++++++++++- src/App.tsx | 212 +++++++++++++++++++++++++++++++++++++++++++++++ src/workspace.ts | 4 +- 4 files changed, 316 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10fe138..712480c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/App.css b/src/App.css index 03a1c1f..b48642a 100644 --- a/src/App.css +++ b/src/App.css @@ -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; } diff --git a/src/App.tsx b/src/App.tsx index 5b16b0c..c7ef68b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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('notebook') + const [parallelTranslationId, setParallelTranslationId] = useState(null) const [marginPanelOrder, setMarginPanelOrder] = useState([ 'margin-note', 'original', @@ -359,6 +361,13 @@ function App() { ]) const [commentaryEntries, setCommentaryEntries] = useState([]) const [dictionaryEntries, setDictionaryEntries] = useState([]) + const [parallelVerses, setParallelVerses] = useState([]) + const [parallelNotes, setParallelNotes] = useState<{ footnotes: Footnote[]; studyNotes: Footnote[] }>({ + footnotes: [], + studyNotes: [], + }) + const [parallelLoading, setParallelLoading] = useState(false) + const [parallelError, setParallelError] = useState(null) const [mapContext, setMapContext] = useState({ 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 ( +
+
+
+ Parallel +

{pinnedVerse ? reference(pinnedVerse) : 'Parallel Translation'}

+
+ {parallelLoading ? 'Loading' : parallelTranslation?.abbreviation ?? 'None'} +
+
+ + +
+ {parallelError ? ( +

{parallelError}

+ ) : currentParallelVerse ? ( + <> +
+

+ {parallelTranslation?.title ?? selectedParallelTranslationId} +

+

{reference(currentParallelVerse)}

+

{currentParallelVerse.text}

+
+
+ {parallelVerses + .filter((verse) => verse.id !== currentParallelVerse.id) + .map((verse) => ( +
+ {reference(verse)} +

{verse.text}

+
+ ))} +
+
+
+

Translation Notes

+ {parallelNotes.footnotes.length ? ( + parallelNotes.footnotes.map((note) => ( +

startTextDrag(event, `${note.reference} ${note.text}`)} + > + {note.reference} + {note.text} +

+ )) + ) : ( +

No translation notes for this verse.

+ )} +
+
+

Study Notes

+ {parallelNotes.studyNotes.length ? ( + parallelNotes.studyNotes.map((note) => ( +

startTextDrag(event, `${note.reference} ${note.text}`)} + > + {note.reference} + {note.text} +

+ )) + ) : ( +

No study notes for this verse.

+ )} +
+
+

+ {parallelTranslation?.licenseName ?? 'Parallel translation package'} + {parallelTranslation?.attribution ? ` - ${parallelTranslation.attribution}` : ''} +

+ + ) : ( +

+ {parallelLoading ? 'Loading parallel verse...' : 'Choose an alternate translation to compare.'} +

+ )} +
+ ) + } + function renderSearchBook() { return (
@@ -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' }, ] diff --git a/src/workspace.ts b/src/workspace.ts index 965fe4a..248e925 100644 --- a/src/workspace.ts +++ b/src/workspace.ts @@ -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', }