Add parallel translation stack book
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user