Fold the Study notes/resources halves; compare translations
- Your notes and Commentary & resources can each be folded so the other fills the right column (remembered on this device; never both folded). - Compare has a Translations view: pick BSB/ESV/NET/NLT/CSB/NASB and read the chunk verse by verse, with each translation's notice. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d8e522dd06
commit
76df145b1e
5 files changed
+216
-22
No files matched your search
@@ -1,9 +1,11 @@
|
||||
// Compare two passages side by side — your notes and the verses — for parallels (the Gospels), repeated themes,
|
||||
// or the same chapter studied twice. Pick any chunk from any of your studies to set beside the one you're on.
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useApp } from '../../context/AppContext.js';
|
||||
import { loadProjectById } from '../../utils/projectStorage.js';
|
||||
import { formatChunkReference, getChunkVerseEntries } from '../../utils/bibleUtils.jsx';
|
||||
import { READER_TRANSLATIONS, fetchChapter, translationName } from '../../utils/bibleText.js';
|
||||
import { alignVerses, translationSpans } from '../../utils/compareTranslations.js';
|
||||
|
||||
const FIELDS = [['observation', 'Observe'], ['interpretation', 'Interpret'], ['application', 'Apply']];
|
||||
|
||||
@@ -35,27 +37,122 @@ function Column({ entry }) {
|
||||
);
|
||||
}
|
||||
|
||||
// One chunk's verses in several translations, a row per verse. Free translations load straight away; licensed ones
|
||||
// come through the server and may be unavailable (no key set), which shows as a note instead of text.
|
||||
function TranslationsView({ project, chunkEntry }) {
|
||||
const spans = useMemo(() => (chunkEntry ? translationSpans(project, chunkEntry.chapterIndex, chunkEntry.chunk) : []), [project, chunkEntry]);
|
||||
const spanKey = spans.map((s) => `${s.book}${s.chapter}:${s.from}-${s.to}`).join(',');
|
||||
const [picked, setPicked] = useState(() => {
|
||||
try { const saved = JSON.parse(localStorage.getItem('compare-translations')); if (Array.isArray(saved) && saved.length) return saved; } catch { /* storage unavailable */ }
|
||||
return [project?.translation ?? 'BSB', 'NET'];
|
||||
});
|
||||
const [loaded, setLoaded] = useState({}); // id -> { status: 'loading'|'ok'|'error', chapters, copyright, error }
|
||||
const toggle = (id) => setPicked((cur) => {
|
||||
const next = cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id];
|
||||
try { localStorage.setItem('compare-translations', JSON.stringify(next)); } catch { /* storage unavailable */ }
|
||||
return next;
|
||||
});
|
||||
|
||||
// The sheet is modal, so the passage can't change while it's open; results are only dropped after it closes.
|
||||
const alive = useRef(true);
|
||||
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
|
||||
useEffect(() => {
|
||||
picked.filter((id) => !loaded[id]).forEach((id) => {
|
||||
setLoaded((cur) => ({ ...cur, [id]: { status: 'loading' } }));
|
||||
Promise.all(spans.map((span) => fetchChapter(id, span.bookAbbrev, Number(span.chapter))
|
||||
.then((res) => [`${span.book}|${span.chapter}`, res])))
|
||||
.then((results) => alive.current && setLoaded((cur) => ({
|
||||
...cur,
|
||||
[id]: { status: 'ok', chapters: Object.fromEntries(results.map(([k, r]) => [k, r.verses])), copyright: results.map(([, r]) => r.copyright).find(Boolean) ?? '' },
|
||||
})))
|
||||
.catch((err) => { if (alive.current) setLoaded((cur) => ({ ...cur, [id]: { status: 'error', error: err.message || 'Unable to load.' } })); });
|
||||
});
|
||||
}, [picked, spanKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const shown = picked.filter((id) => loaded[id]?.status === 'ok');
|
||||
const rows = alignVerses(spans, Object.fromEntries(shown.map((id) => [id, loaded[id].chapters])));
|
||||
const cols = { gridTemplateColumns: `repeat(${Math.max(1, shown.length)}, minmax(0, 1fr))` };
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="flex flex-wrap items-center gap-2" role="group" aria-label="Translations to compare">
|
||||
{READER_TRANSLATIONS.map((t) => {
|
||||
const on = picked.includes(t.id);
|
||||
return (
|
||||
<button key={t.id} type="button" aria-pressed={on} onClick={() => toggle(t.id)} title={t.name}
|
||||
className={`rounded-full border px-3 py-1 text-xs ${on ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>{t.id}</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{picked.map((id) => loaded[id]?.status === 'error' && (
|
||||
<p key={id} role="status" className="text-xs text-[var(--r-sub)]">{id} isn’t available right now: {loaded[id].error}</p>
|
||||
))}
|
||||
{picked.some((id) => loaded[id]?.status === 'loading') && <p className="text-xs text-[var(--r-sub)]">Loading…</p>}
|
||||
{shown.length === 0 && !picked.some((id) => !loaded[id] || loaded[id].status === 'loading') && <p className="text-sm text-[var(--r-sub)]">Pick two or more translations above.</p>}
|
||||
{shown.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="hidden gap-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)] md:grid" style={cols}>
|
||||
{shown.map((id) => <span key={id}>{translationName(id)}</span>)}
|
||||
</div>
|
||||
{rows.map((row) => (
|
||||
<div key={row.key} className="grid gap-x-4 gap-y-1 border-t border-[var(--r-line)] pt-3 md:[grid-template-columns:var(--cols)]" style={{ '--cols': cols.gridTemplateColumns }}>
|
||||
{shown.map((id) => (
|
||||
<p key={id} className="font-serif text-[14px] leading-relaxed">
|
||||
<sup className="mr-1 font-sans text-[var(--r-accent)]">{row.label}</sup>
|
||||
<span className="mr-1 font-sans text-[10px] uppercase tracking-wide text-[var(--r-sub)] md:hidden">{id}</span>
|
||||
{row.texts[id] ?? <span className="text-[var(--r-sub)]">—</span>}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<div className="space-y-1 border-t border-[var(--r-line)] pt-3 text-[11px] leading-snug text-[var(--r-sub)]">
|
||||
{shown.map((id) => {
|
||||
const t = READER_TRANSLATIONS.find((x) => x.id === id);
|
||||
return t?.notice || loaded[id].copyright ? <p key={id}>{loaded[id].copyright || t.notice}</p> : null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CompareSheet({ currentChunkId, onClose }) {
|
||||
const { projectIndex, project } = useApp();
|
||||
const all = useMemo(() => choices(projectIndex, project), [projectIndex, project]);
|
||||
const mine = all.find((c) => c.chunk.id === currentChunkId && c.project.id === project?.id);
|
||||
const [mode, setMode] = useState('passage'); // 'passage' | 'translations'
|
||||
const [otherKey, setOtherKey] = useState('');
|
||||
const other = all.find((c) => c.key === otherKey);
|
||||
const tab = (id) => `rounded-full border px-3 py-1 text-xs ${mode === id ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`;
|
||||
return (
|
||||
<div role="dialog" aria-modal="true" aria-label="Compare passages" className="fixed inset-0 z-[70] flex flex-col bg-[var(--r-bg)] text-[var(--r-text)]">
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-2 border-b border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-2.5">
|
||||
<h2 className="mr-auto text-sm font-medium sm:mr-0">Compare passages</h2>
|
||||
<select aria-label="Compare with" value={otherKey} onChange={(e) => setOtherKey(e.target.value)}
|
||||
className="order-last min-w-0 basis-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm sm:order-none sm:basis-0 sm:flex-1">
|
||||
<option value="">Choose a passage to set beside this one…</option>
|
||||
{all.filter((c) => c.key !== mine?.key).map((c) => <option key={c.key} value={c.key}>{c.label}</option>)}
|
||||
</select>
|
||||
<button type="button" onClick={onClose} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-sm hover:bg-black/5">Close</button>
|
||||
</div>
|
||||
<div className="grid min-h-0 flex-1 overflow-y-auto md:grid-cols-2 md:divide-x md:divide-[var(--r-line)]">
|
||||
<Column entry={mine} />
|
||||
<Column entry={other} />
|
||||
<h2 className="mr-auto text-sm font-medium sm:mr-0">Compare</h2>
|
||||
<div role="tablist" aria-label="What to compare" className="flex gap-1.5">
|
||||
<button type="button" role="tab" aria-selected={mode === 'passage'} onClick={() => setMode('passage')} className={tab('passage')}>Another passage</button>
|
||||
<button type="button" role="tab" aria-selected={mode === 'translations'} onClick={() => setMode('translations')} className={tab('translations')}>Translations</button>
|
||||
</div>
|
||||
{mode === 'passage' && (
|
||||
<select aria-label="Compare with" value={otherKey} onChange={(e) => setOtherKey(e.target.value)}
|
||||
className="order-last min-w-0 basis-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm sm:order-none sm:basis-0 sm:flex-1">
|
||||
<option value="">Choose a passage to set beside this one…</option>
|
||||
{all.filter((c) => c.key !== mine?.key).map((c) => <option key={c.key} value={c.key}>{c.label}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<button type="button" onClick={onClose} className="ml-auto rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-sm hover:bg-black/5">Close</button>
|
||||
</div>
|
||||
{mode === 'passage' ? (
|
||||
<div className="grid min-h-0 flex-1 overflow-y-auto md:grid-cols-2 md:divide-x md:divide-[var(--r-line)]">
|
||||
<Column entry={mine} />
|
||||
<Column entry={other} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<h3 className="px-4 pt-4 font-medium">{mine?.label}</h3>
|
||||
<TranslationsView project={project} chunkEntry={mine} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+35
-10
@@ -43,6 +43,18 @@ export default function StudyPage() {
|
||||
const [tab, setTab] = useResourceTab();
|
||||
const [openWordId, setOpenWordId] = useState(null);
|
||||
const [comparing, setComparing] = useState(false);
|
||||
// Either half of the right column can be folded away so the other fills it (remembered on this device).
|
||||
const [folded, setFolded] = useState(() => {
|
||||
try { const f = JSON.parse(localStorage.getItem('study-folded')); return { notes: !!f?.notes, resources: !!f?.resources && !f?.notes }; } catch { return { notes: false, resources: false }; }
|
||||
});
|
||||
const toggleFold = (which) => setFolded((cur) => {
|
||||
const other = which === 'notes' ? 'resources' : 'notes';
|
||||
// folding the last open half opens the other one instead, so the column is never empty
|
||||
const next = { ...cur, [which]: !cur[which] };
|
||||
if (next[which] && next[other]) next[other] = false;
|
||||
try { localStorage.setItem('study-folded', JSON.stringify(next)); } catch { /* storage unavailable */ }
|
||||
return next;
|
||||
});
|
||||
useEffect(() => { setOpenWordId(null); }, [selectedChunk?.id]);
|
||||
|
||||
const resources = useStudyResources({
|
||||
@@ -132,20 +144,33 @@ export default function StudyPage() {
|
||||
</div>
|
||||
{selectedChunk && chapter && (
|
||||
<>
|
||||
<section aria-label="Notes" className="flex min-h-[22rem] flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] lg:min-h-0 lg:flex-[11]">
|
||||
<h2 className="shrink-0 border-b border-[var(--r-line)] px-4 py-2.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Your notes</h2>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
<section aria-label="Notes" className={`flex flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] ${folded.notes ? 'shrink-0' : 'min-h-[22rem] lg:min-h-0 lg:flex-[11]'}`}>
|
||||
<h2 className="shrink-0">
|
||||
<button type="button" onClick={() => toggleFold('notes')} aria-expanded={!folded.notes}
|
||||
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-[11px] uppercase tracking-wide text-[var(--r-sub)] hover:bg-black/5">
|
||||
Your notes<span aria-hidden="true">{folded.notes ? '▸' : '▾'}</span>
|
||||
</button>
|
||||
</h2>
|
||||
<div className={`min-h-0 flex-1 overflow-y-auto border-t border-[var(--r-line)] p-3 ${folded.notes ? 'hidden' : ''}`}>
|
||||
<NotesPane chunk={selectedChunk} updateChunk={updateChunk} allTags={allTags} templateId={project?.templateDef ?? project?.templateId} saveTemplate={saveUserTemplate} notify={notify}
|
||||
onPray={() => startPrayerFor(formatChunkReference(project, chapterIndex, selectedChunk, '–'))} />
|
||||
</div>
|
||||
</section>
|
||||
<section aria-label="Resources" className="flex min-h-[24rem] flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] lg:min-h-0 lg:flex-[10]">
|
||||
<ResourcePane
|
||||
tab={tab} setTab={setTab} chunk={selectedChunk} chapter={chapter} chapterIndex={chapterIndex} project={project}
|
||||
updateChunk={updateChunk} notify={notify}
|
||||
resources={resources} wordStudy={wordStudy} crossRefs={crossRefs}
|
||||
openWordId={openWordId} setOpenWordId={setOpenWordId}
|
||||
/>
|
||||
<section aria-label="Resources" className={`flex flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] ${folded.resources ? 'shrink-0' : 'min-h-[24rem] lg:min-h-0 lg:flex-[10]'}`}>
|
||||
<h2 className="shrink-0">
|
||||
<button type="button" onClick={() => toggleFold('resources')} aria-expanded={!folded.resources}
|
||||
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-[11px] uppercase tracking-wide text-[var(--r-sub)] hover:bg-black/5">
|
||||
Commentary & resources<span aria-hidden="true">{folded.resources ? '▸' : '▾'}</span>
|
||||
</button>
|
||||
</h2>
|
||||
<div className={`flex min-h-0 flex-1 flex-col border-t border-[var(--r-line)] ${folded.resources ? 'hidden' : ''}`}>
|
||||
<ResourcePane
|
||||
tab={tab} setTab={setTab} chunk={selectedChunk} chapter={chapter} chapterIndex={chapterIndex} project={project}
|
||||
updateChunk={updateChunk} notify={notify}
|
||||
resources={resources} wordStudy={wordStudy} crossRefs={crossRefs}
|
||||
openWordId={openWordId} setOpenWordId={setOpenWordId}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -435,6 +435,19 @@ describe('Study workspace', () => {
|
||||
expect(await screen.findByRole('button', { name: /add to notes/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('either half of the right column can be folded, and the other always stays open', async () => {
|
||||
const user = await studyOneChunk();
|
||||
const notes = screen.getByRole('button', { name: /your notes/i });
|
||||
const resources = screen.getByRole('button', { name: /commentary & resources/i });
|
||||
expect(notes).toHaveAttribute('aria-expanded', 'true');
|
||||
await user.click(notes);
|
||||
expect(notes).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(resources).toHaveAttribute('aria-expanded', 'true');
|
||||
await user.click(resources); // folding the last open half opens the other one
|
||||
expect(resources).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(notes).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
test('people & places: lists what is tagged in the chunk', async () => {
|
||||
const user = await studyOneChunk([['/d/theographic/TIT/1.json', { chapter: { people: [{ id: 'paul_1', name: 'Paul', verses: [1] }, { id: 'x_2', name: 'Elsewhere', verses: [9] }], places: [], events: [] } }]]);
|
||||
await user.click(screen.getByRole('tab', { name: /people & places/i }));
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Comparing translations of one chunk: which chapter(s) and verses to fetch, and the verses lined up by number.
|
||||
import { getChunkVerseEntries } from './bibleUtils.jsx';
|
||||
|
||||
// -> [{ bookAbbrev, book, chapter, from, to }]: one entry per chapter the chunk covers (two when it spills over).
|
||||
export function translationSpans(project, chapterIndex, chunk) {
|
||||
const spans = [];
|
||||
for (const v of getChunkVerseEntries(project, chapterIndex, chunk)) {
|
||||
const last = spans[spans.length - 1];
|
||||
if (last && last.chapter === v.chapter && last.book === v.book) { last.to = Math.max(last.to, v.number); last.from = Math.min(last.from, v.number); continue; }
|
||||
const chapter = project.chapters.find((c) => c.book === v.book && String(c.chapter) === String(v.chapter));
|
||||
spans.push({ bookAbbrev: chapter?.bookAbbrev, book: v.book, chapter: v.chapter, from: v.number, to: v.number });
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
// byTranslation: { BSB: { verses: [{ number, text }] }, … } for the chapters in `spans`; `loaded` is keyed by
|
||||
// `${chapter}` inside each translation. -> [{ key, label, texts: { BSB: 'text' | null } }] one row per verse, in order.
|
||||
export function alignVerses(spans, byTranslation) {
|
||||
const rows = [];
|
||||
for (const span of spans) {
|
||||
for (let n = span.from; n <= span.to; n += 1) {
|
||||
const texts = {};
|
||||
for (const [id, chapters] of Object.entries(byTranslation)) {
|
||||
texts[id] = chapters?.[`${span.book}|${span.chapter}`]?.find((v) => v.number === n)?.text ?? null;
|
||||
}
|
||||
rows.push({ key: `${span.book}${span.chapter}:${n}`, label: spans.length > 1 ? `${span.chapter}:${n}` : String(n), texts });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { test, expect } from 'vitest';
|
||||
import { translationSpans, alignVerses } from './compareTranslations.js';
|
||||
|
||||
const verses = (from, to) => Array.from({ length: to - from + 1 }, (_, i) => ({ number: from + i, text: `v${from + i}` }));
|
||||
const project = { chapters: [
|
||||
{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: verses(1, 36), chunks: [] },
|
||||
{ book: 'John', bookAbbrev: 'JHN', chapter: '4', verses: verses(1, 54), chunks: [] },
|
||||
] };
|
||||
|
||||
test('a chunk inside one chapter is a single span', () => {
|
||||
expect(translationSpans(project, 0, { startVerse: 16, endVerse: 18, spilloverEndVerse: null }))
|
||||
.toEqual([{ bookAbbrev: 'JHN', book: 'John', chapter: '3', from: 16, to: 18 }]);
|
||||
});
|
||||
|
||||
test('a chunk that runs into the next chapter has a span for each', () => {
|
||||
const spans = translationSpans(project, 0, { startVerse: 35, endVerse: 36, spilloverEndVerse: 2 });
|
||||
expect(spans.map((s) => [s.chapter, s.from, s.to])).toEqual([['3', 35, 36], ['4', 1, 2]]);
|
||||
});
|
||||
|
||||
test('verses are lined up by number, with null where a translation lacks one', () => {
|
||||
const spans = [{ book: 'John', chapter: '3', from: 16, to: 17 }];
|
||||
const rows = alignVerses(spans, {
|
||||
BSB: { 'John|3': [{ number: 16, text: 'a' }, { number: 17, text: 'b' }] },
|
||||
NET: { 'John|3': [{ number: 16, text: 'c' }] },
|
||||
});
|
||||
expect(rows.map((r) => r.label)).toEqual(['16', '17']);
|
||||
expect(rows[0].texts).toEqual({ BSB: 'a', NET: 'c' });
|
||||
expect(rows[1].texts).toEqual({ BSB: 'b', NET: null });
|
||||
});
|
||||
Reference in new issue
Block a user