diff --git a/src/components/ScriptureRefs.jsx b/src/components/ScriptureRefs.jsx new file mode 100644 index 0000000..fe2803e --- /dev/null +++ b/src/components/ScriptureRefs.jsx @@ -0,0 +1,63 @@ +// Under a note: each Scripture reference written in it (Rom 8:28, Psalm 23…) as a chip. Click one to read the verse +// (BSB) right there, or open it in the Bible reader. The text itself is never changed. +import { useEffect, useMemo, useState } from 'react'; +import { useApp } from '../context/AppContext.js'; +import { fetchChapter } from '../utils/bibleText.js'; +import { labelFor, uniqueReferences } from '../utils/scriptureRefs.js'; + +const MAX_VERSES = 12; +const CHAPTER_PREVIEW = 6; + +function Preview({ r }) { + const { jumpToReaderVerse, openBibleReader } = useApp(); + const [state, setState] = useState({ loading: true }); + useEffect(() => { + let live = true; + setState({ loading: true }); + fetchChapter('BSB', r.book, r.chapter) + .then(({ verses }) => { + const from = r.verse ?? 1; + const to = r.verse ? (r.endVerse ?? r.verse) : from + CHAPTER_PREVIEW - 1; + const picked = verses.filter((v) => v.number >= from && v.number <= to).slice(0, MAX_VERSES); + if (live) setState({ verses: picked, more: !r.verse && verses.length > picked.length }); + }) + .catch((err) => { if (live) setState({ error: err.message || 'Could not load the verse.' }); }); + return () => { live = false; }; + }, [r.book, r.chapter, r.verse, r.endVerse]); + + return ( +
+ {state.loading &&

Loading…

} + {state.error &&

{state.error} You can still open it in the Bible.

} + {state.verses && (state.verses.length === 0 + ?

That verse isn’t in the BSB.

+ :
{state.verses.map((v) =>

{v.number}{v.text}

)}{state.more &&

…

}
)} +
+ + Berean Standard Bible +
+
+ ); +} + +export default function ScriptureRefs({ text }) { + const refs = useMemo(() => uniqueReferences(text), [text]); + const [open, setOpen] = useState(null); + if (refs.length === 0) return null; + const openRef = refs.find((r) => labelFor(r) === open); + return ( +
+

+ Verses mentioned: + {refs.map((r) => { + const label = labelFor(r); + return ( + + ); + })} +

+ {openRef && } +
+ ); +} diff --git a/src/components/ScriptureRefs.test.jsx b/src/components/ScriptureRefs.test.jsx new file mode 100644 index 0000000..ed30231 --- /dev/null +++ b/src/components/ScriptureRefs.test.jsx @@ -0,0 +1,47 @@ +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { vi, test, expect, beforeEach } from 'vitest'; +import { AppContext } from '../context/AppContext.js'; +import ScriptureRefs from './ScriptureRefs.jsx'; + +const jump = vi.fn(); +const open = vi.fn(); +beforeEach(() => { + jump.mockClear(); open.mockClear(); + vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: true, status: 200, + json: async () => ({ book: { numberOfChapters: 16 }, verses: [28, 29, 30].map((n) => ({ number: n, text: `Verse ${n} text.` })) }), + }))); +}); +const setup = (text) => render(); + +test('references in a note become chips, and a note with none shows nothing', () => { + const { container } = setup('Nothing to see here.'); + expect(container).toBeEmptyDOMElement(); +}); + +test('clicking a reference shows the verse, and "Read in the Bible" opens the reader there', async () => { + const user = userEvent.setup(); + setup('God works all things together, Rom 8:28-29. See also Romans 8:28.'); + expect(screen.getAllByRole('button', { name: /Romans 8/ })).toHaveLength(2); // 8:28–29 and 8:28 are different references + await user.click(screen.getByRole('button', { name: 'Romans 8:28–29' })); + const preview = await screen.findByRole('region', { name: /Romans 8:28–29 preview/ }); + expect(await within(preview).findByText('Verse 28 text.')).toBeInTheDocument(); + expect(within(preview).getByText('Verse 29 text.')).toBeInTheDocument(); + expect(within(preview).queryByText('Verse 30 text.')).not.toBeInTheDocument(); + await user.click(within(preview).getByRole('button', { name: 'Read in the Bible' })); + expect(jump).toHaveBeenCalledWith('ROM', 8, 28); + expect(open).toHaveBeenCalled(); + await user.click(screen.getByRole('button', { name: 'Romans 8:28–29' })); + expect(screen.queryByRole('region')).not.toBeInTheDocument(); +}); + +test('a failure to load the verse still leaves the link to the Bible', async () => { + vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); })); + const user = userEvent.setup(); + setup('Psalm 23:1'); + await user.click(screen.getByRole('button', { name: 'Psalms 23:1' })); + const preview = await screen.findByRole('region'); + expect(await within(preview).findByText(/You can still open it in the Bible/)).toBeInTheDocument(); + expect(within(preview).getByRole('button', { name: 'Read in the Bible' })).toBeInTheDocument(); +}); diff --git a/src/components/study/NotesPane.jsx b/src/components/study/NotesPane.jsx index 67c644e..b567876 100644 --- a/src/components/study/NotesPane.jsx +++ b/src/components/study/NotesPane.jsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react'; import SessionNotes from './SessionNotes.jsx'; import FirstDraft from './FirstDraft.jsx'; +import ScriptureRefs from '../ScriptureRefs.jsx'; import { usePasteCheck } from './PastePrompt.jsx'; import { listNoteVersions, recordNoteVersion } from '../../utils/noteHistory.js'; import DictateButton, { appendSpoken } from '../DictateButton.jsx'; @@ -108,6 +109,7 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav