IQ Bible on the Study side: commentaries, lexicons, study aids, outline
Build and Push Docker Image / build-and-push (push) Failing after 19s
Build and Push Docker Image / build-and-push (push) Failing after 19s
- Commentary tab: "More sources" switch with notes for the whole chunk and "Add to notes" quoting - Word study: Lexicons button for words with a Strong's number - New "Study aids" tab (any verse of the chunk; topics add as tags) - Outline points: "Add from IQ Bible" attaches topics, Gospel parallels, manuscript notes and places Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8939a7f49f
commit
f70f1baf15
12 files changed
+281
-11
No files matched your search
@@ -0,0 +1,29 @@
|
||||
// The Study page's "Study aids" tab: IQ Bible's aids for a verse of the chunk being studied (the first by
|
||||
// default). Topics can be added to the chunk as tags with one tap.
|
||||
import { useState } from 'react';
|
||||
import IqStudyAids from './IqStudyAids.jsx';
|
||||
|
||||
export default function IqChunkAids({ chunk, chapter, updateChunk, notify }) {
|
||||
const [picked, setPicked] = useState(null);
|
||||
const last = Math.min(chunk.endVerse, chunk.startVerse + 59);
|
||||
const verse = picked != null && picked >= chunk.startVerse && picked <= last ? picked : chunk.startVerse;
|
||||
const verses = Array.from({ length: last - chunk.startVerse + 1 }, (_, i) => chunk.startVerse + i);
|
||||
const addTag = (name) => {
|
||||
const tags = chunk.tags ?? [];
|
||||
if (tags.some((t) => t.toLowerCase() === name.toLowerCase())) { notify(`“${name}” is already a tag.`, 1600); return; }
|
||||
updateChunk(chunk.id, { tags: [...tags, name] });
|
||||
notify(`Added the tag “${name}”.`, 1600);
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
{verses.length > 1 && (
|
||||
<label className="mb-3 block text-xs text-[var(--r-sub)]">Aids for verse{' '}
|
||||
<select value={verse} onChange={(e) => setPicked(Number(e.target.value))} className="rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1 text-xs text-[var(--r-text)]">
|
||||
{verses.map((v) => <option key={v} value={v}>{v}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<IqStudyAids key={`${chapter.bookAbbrev}${chapter.chapter}${verse}`} book={chapter.bookAbbrev} chapter={Number(chapter.chapter)} verse={verse} onTag={addTag} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,13 +7,13 @@ import { listOf, pickIqEntries } from '../../utils/iqbible.js';
|
||||
const KEY = 'iq-commentary';
|
||||
const muted = 'text-xs text-[var(--r-sub,#64748b)]';
|
||||
|
||||
export default function IqCommentary({ book, chapter, verse }) {
|
||||
export default function IqCommentary({ book, chapter, verse, endVerse = verse, onQuote }) {
|
||||
const [chosen, setChosen] = useState(() => { try { return localStorage.getItem(KEY) || 'mhenry'; } catch { return 'mhenry'; } });
|
||||
const sources = useIq(`books/${book}/commentaries`, { chapter });
|
||||
const list = listOf(sources.data);
|
||||
const source = list.find((s) => s.name === chosen) ?? list.find((s) => s.name === 'mhenry') ?? list[0];
|
||||
const notes = useIq(source ? `commentaries/${source.name}/${book}/${chapter}` : null);
|
||||
const entries = pickIqEntries(notes.data?.entries, chapter, verse);
|
||||
const entries = pickIqEntries(notes.data?.entries, chapter, verse, endVerse);
|
||||
|
||||
const pick = (name) => { setChosen(name); try { localStorage.setItem(KEY, name); } catch { /* storage unavailable */ } };
|
||||
const groups = list.reduce((acc, s) => { (acc[s.category || 'Other'] ??= []).push(s); return acc; }, {});
|
||||
@@ -38,15 +38,16 @@ export default function IqCommentary({ book, chapter, verse }) {
|
||||
{notes.status === 'loading' && <p className={muted}>Loading…</p>}
|
||||
{notes.status === 'error' && <p className={muted}>{notes.error}</p>}
|
||||
{(notes.status === 'empty' || (notes.status === 'ready' && entries.length === 0)) && (
|
||||
<p className={muted}>{source?.author_name} has no notes at or before verse {verse} in this chapter.</p>
|
||||
<p className={muted}>{source?.author_name} has no notes at or before {endVerse > verse ? `verses ${verse}–${endVerse}` : `verse ${verse}`} in this chapter.</p>
|
||||
)}
|
||||
{entries.map((e, i) => (
|
||||
<div key={i}>
|
||||
<p className="mb-1 text-[11px] text-[var(--r-sub,#64748b)]">
|
||||
{e.verse !== verse || e.verse_end ? `Notes on verse${e.verse_end && e.verse_end !== e.verse ? `s ${e.verse}–${e.verse_end}` : ` ${e.verse}`}` : ''}
|
||||
{e.verse !== verse || e.verse_end || endVerse > verse ? `Notes on verse${e.verse_end && e.verse_end !== e.verse ? `s ${e.verse}–${e.verse_end}` : ` ${e.verse}`}` : ''}
|
||||
{e.source_title ? ` · ${e.source_title}` : ''}
|
||||
</p>
|
||||
<div className="max-h-96 overflow-y-auto whitespace-pre-line rounded-xl bg-[var(--r-bg,#ffffff)] p-3 text-[13px] leading-relaxed text-[var(--r-text,#334155)]">{e.text}</div>
|
||||
{onQuote && <button type="button" onClick={() => onQuote(e.text, e.verse, source?.author_name)} className="mt-0.5 text-[11px] text-[var(--r-accent)] opacity-70 hover:underline hover:opacity-100">Add to notes</button>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// "Add from IQ Bible" for an outline point: pick a topic, Gospel parallel, manuscript note or place for the
|
||||
// point's passage and it is attached to the point like a cross-reference or word. Nothing is fetched until
|
||||
// the picker is opened.
|
||||
import { useState } from 'react';
|
||||
import { useIq, useIqAvailable } from '../../hooks/useIq.js';
|
||||
import { listOf } from '../../utils/iqbible.js';
|
||||
|
||||
const GOSPELS = ['MAT', 'MRK', 'LUK', 'JHN'];
|
||||
|
||||
// -> [{ id, label, detail, group }] from the four responses (each may be absent)
|
||||
export function aidOptions({ topics, parallels, variants, places }) {
|
||||
const out = [];
|
||||
listOf(topics).forEach((t) => t?.name && out.push({ id: `iq:topic:${t.name}`, group: 'Topics', label: t.name, detail: t.label || undefined }));
|
||||
listOf(parallels).forEach((s) => s?.number && out.push({
|
||||
id: `iq:harmony:${s.number}`, group: 'Gospel parallels',
|
||||
label: (s.references ?? []).map((r) => r.citation ?? r).join('; ') || `Harmony §${s.number}`,
|
||||
detail: s.title ? String(s.title).toLowerCase().replace(/(^|[.\s])([a-z])/g, (m, a, b) => a + b.toUpperCase()) : undefined,
|
||||
}));
|
||||
listOf(variants).forEach((v) => v?.title && out.push({ id: `iq:variant:${v.slug ?? v.title}`, group: 'Manuscript notes', label: v.title, detail: v.explanation ? String(v.explanation).slice(0, 240) : undefined }));
|
||||
listOf(places).forEach((p) => p?.name && out.push({ id: `iq:place:${p.id ?? p.name}`, group: 'Places', label: p.name, detail: [p.place_type, p.modern_name && p.modern_name !== p.name ? `today: ${p.modern_name}` : ''].filter(Boolean).join(' · ') || undefined }));
|
||||
return out;
|
||||
}
|
||||
|
||||
export default function IqOutlineAttach({ book, chapter, verse, attachedIds, onAttach, selectClass = '' }) {
|
||||
const on = useIqAvailable();
|
||||
const [open, setOpen] = useState(false);
|
||||
const q = (path) => (open ? path : null);
|
||||
const topics = useIq(q(`topics/${book}/${chapter}/${verse}`));
|
||||
const parallels = useIq(q(GOSPELS.includes(book) ? 'harmony' : null), { book, chapter });
|
||||
const variants = useIq(q(`textual-variants/${book}/${chapter}/${verse}`));
|
||||
const places = useIq(q(`geo/${book}/${chapter}/${verse}`));
|
||||
if (!on) return null;
|
||||
if (!open) {
|
||||
return <button type="button" onClick={() => setOpen(true)} className="mt-2 text-xs font-medium text-[var(--r-accent)] hover:underline">Add from IQ Bible…</button>;
|
||||
}
|
||||
const loading = [topics, parallels, variants, places].some((r) => r.status === 'loading');
|
||||
const options = aidOptions({ topics: topics.data, parallels: parallels.data, variants: variants.data, places: places.data }).filter((o) => !attachedIds.has(o.id));
|
||||
const groups = options.reduce((acc, o) => { (acc[o.group] ??= []).push(o); return acc; }, {});
|
||||
return (
|
||||
<div className="mt-2">
|
||||
{loading && <p className="text-xs text-[var(--r-sub)]">Loading from IQ Bible…</p>}
|
||||
{!loading && options.length === 0 && <p className="text-xs text-[var(--r-sub)]">Nothing more to add for this passage.</p>}
|
||||
{options.length > 0 && (
|
||||
<select value="" aria-label="Attach from IQ Bible" className={selectClass}
|
||||
onChange={(e) => { const pick = options.find((o) => o.id === e.target.value); if (pick) onAttach({ id: pick.id, type: 'see', label: pick.label, detail: pick.detail }); }}>
|
||||
<option value="">Choose a topic, parallel, note or place…</option>
|
||||
{Object.entries(groups).map(([group, items]) => (
|
||||
<optgroup key={group} label={group}>{items.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}</optgroup>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button type="button" onClick={() => setOpen(false)} className="mt-1 text-[11px] text-[var(--r-sub)] hover:underline">Close</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -101,7 +101,7 @@ function Harmony({ data, book, chapter }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function IqStudyAids({ book, chapter, verse }) {
|
||||
export default function IqStudyAids({ book, chapter, verse, onTag }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Aid title="About this chapter" path={`books/${book}/chapters/${chapter}/info`} empty="No chapter overview is available.">
|
||||
@@ -116,7 +116,9 @@ export default function IqStudyAids({ book, chapter, verse }) {
|
||||
{(d) => (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{listOf(d).slice(0, 40).map((t, i) => (
|
||||
<span key={i} className="rounded-full border border-[var(--r-line,#cbd5e1)] px-2 py-0.5 text-xs" title={t.label || t.edition}>{t.name}</span>
|
||||
onTag
|
||||
? <button key={i} type="button" onClick={() => onTag(t.name)} className="rounded-full border border-[var(--r-line,#cbd5e1)] px-2 py-0.5 text-xs hover:bg-black/5" title={`Add “${t.name}” as a tag on this chunk`}>+ {t.name}</button>
|
||||
: <span key={i} className="rounded-full border border-[var(--r-line,#cbd5e1)] px-2 py-0.5 text-xs" title={t.label || t.edition}>{t.name}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -159,3 +159,59 @@ describe('IqDailyCard', () => {
|
||||
expect(screen.getByText(/Couldn’t load NLT just now/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Study side', () => {
|
||||
const chunk = { id: 'c1', startVerse: 14, endVerse: 16, tags: ['love'], generalNotes: '' };
|
||||
const chapter = { book: 'John', bookAbbrev: 'JHN', chapter: '3' };
|
||||
|
||||
test('topics from the aids tab can be added to the chunk as tags (once)', async () => {
|
||||
const { default: IqChunkAids } = await import('./IqChunkAids.jsx');
|
||||
routes['/api/iq/topics/JHN/3/14'] = { data: [{ name: 'faith' }, { name: 'love' }] };
|
||||
const updateChunk = vi.fn();
|
||||
const notify = vi.fn();
|
||||
render(<IqChunkAids chunk={chunk} chapter={chapter} updateChunk={updateChunk} notify={notify} />);
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByText('Topics on this verse'));
|
||||
await user.click(await screen.findByRole('button', { name: /\+ faith/ }));
|
||||
expect(updateChunk).toHaveBeenCalledWith('c1', { tags: ['love', 'faith'] });
|
||||
await user.click(screen.getByRole('button', { name: /\+ love/ }));
|
||||
expect(updateChunk).toHaveBeenCalledTimes(1); // already a tag
|
||||
expect(notify).toHaveBeenLastCalledWith('“love” is already a tag.', 1600);
|
||||
});
|
||||
|
||||
test('the aids can be read for any verse of the chunk', async () => {
|
||||
const { default: IqChunkAids } = await import('./IqChunkAids.jsx');
|
||||
routes['/api/iq/topics/JHN/3/16'] = { data: [{ name: 'eternal life' }] };
|
||||
render(<IqChunkAids chunk={chunk} chapter={chapter} updateChunk={() => {}} notify={() => {}} />);
|
||||
const user = userEvent.setup();
|
||||
await user.selectOptions(screen.getByRole('combobox'), '16');
|
||||
await user.click(screen.getByText('Topics on this verse'));
|
||||
expect(await screen.findByText('+ eternal life')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('outline attachments are offered from topics, parallels and notes, minus those already attached', async () => {
|
||||
const { aidOptions } = await import('./IqOutlineAttach.jsx');
|
||||
const options = aidOptions({
|
||||
topics: { data: [{ name: 'faith' }, { name: 'grace', label: 'Of God' }] },
|
||||
parallels: { data: [{ number: '72', title: 'FEEDING OF THE FIVE THOUSAND', references: [{ citation: 'Mark 6:30-44' }, { citation: 'John 6:1-13' }] }] },
|
||||
variants: { data: [{ slug: 'mark-16', title: 'The Longer Ending of Mark', explanation: 'Long.' }] },
|
||||
places: { data: [{ id: 5, name: 'Capernaum', place_type: 'settlement' }] },
|
||||
});
|
||||
expect(options.map((o) => o.group)).toEqual(['Topics', 'Topics', 'Gospel parallels', 'Manuscript notes', 'Places']);
|
||||
expect(options[2]).toMatchObject({ id: 'iq:harmony:72', label: 'Mark 6:30-44; John 6:1-13', detail: 'Feeding Of The Five Thousand' });
|
||||
});
|
||||
|
||||
test('the outline picker fetches nothing until it is opened, then attaches the choice', async () => {
|
||||
const { default: IqOutlineAttach } = await import('./IqOutlineAttach.jsx');
|
||||
routes['/api/iq/topics/JHN/3/14'] = { data: [{ name: 'faith' }, { name: 'grace' }] };
|
||||
const onAttach = vi.fn();
|
||||
render(<IqOutlineAttach book="JHN" chapter={3} verse={14} attachedIds={new Set(['iq:topic:grace'])} onAttach={onAttach} />);
|
||||
const user = userEvent.setup();
|
||||
const button = await screen.findByRole('button', { name: /add from iq bible/i });
|
||||
expect(asked.some((u) => u.includes('/topics/'))).toBe(false);
|
||||
await user.click(button);
|
||||
await user.selectOptions(await screen.findByLabelText('Attach from IQ Bible'), 'iq:topic:faith');
|
||||
expect(onAttach).toHaveBeenCalledWith({ id: 'iq:topic:faith', type: 'see', label: 'faith', detail: undefined });
|
||||
expect(screen.queryByRole('option', { name: 'grace' })).toBeNull(); // already attached
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user