Files
study-bible-project/src/components/reader/VerseSheet.jsx
T
nmemmertandClaude Sonnet 5.5 bdb04cdedc
Build and Push Docker Image / build-and-push (push) Successful in 10m22s
Add cross-reference map, admin health page, passkeys and device list, settings tabs
- Reader: chapter cross-reference map (M) from IQ Bible's reference graph
- Admin: Health tab with disk use, snapshots (back up, download, restore), failure logs
- Passkey sign-in (WebAuthn), signed-in device list, sign out others, optional sign-in alerts
- Settings split into Account, Preferences, Notifications, Security, Your data tabs
- ntfy server field: catch a topic typed into the server box

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 12:52:49 -04:00

304 lines
18 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The single home for per-verse tools: original words, cross-references,
// commentary, and people & places, plus bookmark / copy.
import { useEffect, useRef, useState } from 'react';
import Sheet from './Sheet.jsx';
import { CommentaryTab, PeoplePlacesTab } from '../StudyPanel.jsx';
import { BookmarkIcon, CopyIcon } from '../../utils/bibleUtils.jsx';
import { formatCrossRef, speakOriginalWord } from '../../utils/wordStudy.js';
import { READER_TRANSLATIONS, fetchChapter } from '../../utils/bibleText.js';
import { diffVerses, differenceLabel } from '../../utils/textDiff.js';
import { DiffText } from './DiffText.jsx';
import { HIGHLIGHT_COLORS, colorTitle } from '../../utils/highlightColors.js';
import { isWholeVerse } from '../../utils/highlightSpans.js';
import { useIqAvailable } from '../../hooks/useIq.js';
import MarkNote from './MarkNote.jsx';
import VerseShareImage from '../VerseShareImage.jsx';
import IqLexicon from '../iq/IqLexicon.jsx';
import IqStudyAids from '../iq/IqStudyAids.jsx';
const BASE_TABS = [['commentary', 'Commentary'], ['compare', 'Compare'], ['original', 'Original'], ['refs', 'Cross-refs'], ['people', 'People & places']];
function OriginalTab({ words }) {
const iq = useIqAvailable();
const [lookup, setLookup] = useState(null); // the Strong's number open in the lexicon panel
if (!words) return <p className="text-sm text-[var(--r-sub)]">No original-language data for this verse.</p>;
return (
<>
<div className="flex flex-wrap gap-2">
{words.map((w, i) => {
const isHebrew = w.s?.startsWith('H');
const canSpeak = isHebrew || w.s?.startsWith('G');
return (
<div key={i} className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-2 text-center" style={{ minWidth: '4.5rem', maxWidth: '9rem' }}>
<div className={`text-lg leading-tight ${isHebrew ? 'font-serif' : ''}`} dir={isHebrew ? 'rtl' : 'ltr'}>{w.o}</div>
<div className="mt-0.5 text-xs italic text-[var(--r-sub)]">{w.t}</div>
<div className="mt-1 text-xs font-medium">{w.g}</div>
{w.p && <div className="mt-0.5 text-[10px] leading-tight text-[var(--r-sub)]">{w.p}</div>}
{w.s && <div className="mt-0.5 text-[10px] text-[var(--r-sub)]">{w.s}</div>}
{canSpeak && (
<button type="button" onClick={() => speakOriginalWord(w.o, w.s)}
className="mt-1.5 rounded-lg bg-[var(--r-bar)] px-2 py-0.5 text-[11px] hover:bg-black/5"
title={`Pronounce in ${isHebrew ? 'Hebrew' : 'Greek'}`}>Speak</button>
)}
{iq && w.s && (
<button type="button" onClick={() => setLookup((cur) => (cur === w.s ? null : w.s))} aria-pressed={lookup === w.s}
className="mt-1.5 ml-1 rounded-lg bg-[var(--r-bar)] px-2 py-0.5 text-[11px] hover:bg-black/5" title="Look this word up in the lexicons">Lexicon</button>
)}
</div>
);
})}
</div>
{lookup && <IqLexicon strongs={lookup} onClose={() => setLookup(null)} />}
</>
);
}
// A cross-referenced passage shown in the panel: its words in the translation being read, without
// leaving the chapter. "Open chapter" goes there when you want more.
function RefPreview({ refItem, translation, onOpen, onClose }) {
const [state, setState] = useState({ key: '', verses: [], error: '' });
const key = `${translation}/${refItem.book}/${refItem.chapter}`;
useEffect(() => {
const controller = new AbortController();
fetchChapter(translation, refItem.book, refItem.chapter, { signal: controller.signal })
.then(({ verses }) => setState({ key, verses, error: '' }))
.catch((err) => { if (!controller.signal.aborted) setState({ key, verses: [], error: err.message || 'Unable to load that passage.' }); });
return () => controller.abort();
}, [key]); // eslint-disable-line react-hooks/exhaustive-deps
const end = refItem.endVerse && refItem.endVerse > refItem.verse ? refItem.endVerse : refItem.verse;
const picked = state.key === key ? state.verses.filter((v) => v.number >= refItem.verse && v.number <= end) : [];
const loading = state.key !== key;
return (
<div className="mb-3 rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-3" role="region" aria-label={`Cross-reference ${formatCrossRef(refItem)}`}>
<div className="mb-1.5 flex items-center justify-between gap-2">
<p className="text-sm font-medium">{formatCrossRef(refItem)} <span className="text-xs font-normal text-[var(--r-sub)]">{translation}</span></p>
<button type="button" onClick={onClose} aria-label="Close cross-reference" className="rounded p-1 text-[var(--r-sub)] hover:bg-black/5">×</button>
</div>
{loading ? <p className="text-sm text-[var(--r-sub)]">Loading…</p>
: state.error ? <p className="text-sm text-[var(--r-danger,#b42318)]">{state.error}</p>
: picked.length === 0 ? <p className="text-sm text-[var(--r-sub)]">Those verses weren’t found.</p>
: (
<p className="font-serif text-[15px] leading-relaxed">
{picked.map((v) => <span key={v.number}>{picked.length > 1 && <sup className="mr-0.5 font-sans text-[0.65em] text-[var(--r-sub)]">{v.number}</sup>}{v.text}{' '}</span>)}
</p>
)}
<button type="button" onClick={() => onOpen(refItem.book, refItem.chapter, refItem.verse)} className="mt-2 text-xs font-medium text-[var(--r-accent)] hover:underline">Open chapter</button>
</div>
);
}
const sameRef = (a, b) => a && b && a.book === b.book && a.chapter === b.chapter && a.verse === b.verse && (a.endVerse ?? a.verse) === (b.endVerse ?? b.verse);
function RefsTab({ refs, loading, active, onPick, preview }) {
if (loading && !refs) return <p className="text-sm text-[var(--r-sub)]">Loading…</p>;
if (!refs?.length) return <p className="text-sm text-[var(--r-sub)]">No cross-references for this verse.</p>;
return (
<>
{preview}
<p className="mb-1.5 text-[11px] text-[var(--r-sub)]">Tap one to read it here.</p>
<div className="flex flex-wrap gap-1.5">
{refs.slice(0, 20).map((ref, i) => (
<button key={i} type="button" onClick={() => onPick(ref)} aria-pressed={sameRef(active, ref)}
className={`rounded-full border px-2.5 py-1 text-xs ${sameRef(active, ref) ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] hover:bg-black/5'}`}>
{formatCrossRef(ref)}
</button>
))}
</div>
</>
);
}
// This verse in every translation, one under the other. Each Bible loads the chapter it is in
// (BSB and NET come from the free source; the rest through the server) and shows this verse.
function CompareTab({ book, chapter, verse, current }) {
const [rows, setRows] = useState({}); // id -> { text } | { error } | undefined while loading
useEffect(() => {
const controller = new AbortController();
setRows({});
READER_TRANSLATIONS.forEach((t) => {
fetchChapter(t.id, book, chapter, { signal: controller.signal })
.then(({ verses }) => {
const text = verses.find((v) => v.number === verse)?.text;
setRows((r) => ({ ...r, [t.id]: text ? { text } : { error: 'No verse here in this translation.' } }));
})
.catch((err) => { if (!controller.signal.aborted) setRows((r) => ({ ...r, [t.id]: { error: err.message || 'Unavailable.' } })); });
});
return () => controller.abort();
}, [book, chapter, verse]);
const mine = rows[current]?.text;
return (
<div className="space-y-3">
{READER_TRANSLATIONS.map((t) => {
const row = rows[t.id];
// How this wording differs from the Bible being read, word by word
const d = row?.text && mine && t.id !== current ? diffVerses(mine, row.text) : null;
return (
<div key={t.id}>
<p className="mb-0.5 flex items-center gap-2 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">
<span>{t.id}{t.id === current ? ' · reading' : ''}</span>
{d && <span className="normal-case tracking-normal">{differenceLabel(d.similarity)}</span>}
</p>
{!row ? <p className="text-sm text-[var(--r-sub)]">Loading…</p>
: row.error ? <p className="text-sm text-[var(--r-sub)]">{row.error}</p>
: <p className="font-serif text-[15px] leading-relaxed"><DiffText text={row.text} ranges={d?.b} /></p>}
</div>
);
})}
<p className="border-t border-[var(--r-line)] pt-2 text-[10px] leading-snug text-[var(--r-sub)]">
{mine ? <><span className="diff-hl">Marked words</span> aren’t in the {current} wording. </> : null}
Copyright notices for each translation appear at the foot of the chapter when you read it.
</p>
</div>
);
}
export default function VerseSheet({
verse, book, bookName, chapter, translation, words, crossRefs, crossRefsLoading, onLoadCrossRefs,
bookmarkColor, onToggleBookmark, onCycleColor, bookmarkNote = '', onSetBookmarkNote, onSetHighlightNote, studiesHere = [], onOpenStudy, onPray, onOpenMap, onJump, onClose, request,
verseHighlights = [], colorLabels = {}, onHighlightVerse, onClearVerseHighlights, panelRef, resizeHandle,
}) {
const iqOn = useIqAvailable();
const TABS = iqOn ? [...BASE_TABS, ['aids', 'Study aids']] : BASE_TABS;
const [tab, setTab] = useState('commentary');
const [copied, setCopied] = useState(false);
const [imageOpen, setImageOpen] = useState(false);
const [preview, setPreview] = useState(null); // the cross-reference being read in the panel
const appliedRequest = useRef(0);
// Leaving this verse closes the preview; a cross-reference tapped in the text opens it here. (Tracks the
// previous verse so React's double-run of effects in development can't clear a preview it just opened.)
const previousVerse = useRef(null);
useEffect(() => {
const verseChanged = previousVerse.current !== verse?.number;
previousVerse.current = verse?.number;
if (request && request.verse === verse?.number && request.n !== appliedRequest.current) {
appliedRequest.current = request.n;
setTab('refs');
setPreview(request.ref);
} else if (verseChanged) {
setPreview(null);
}
}, [verse?.number, request?.n]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { if (verse && tab === 'refs') onLoadCrossRefs(); }, [verse?.number, tab]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { setCopied(false); setImageOpen(false); }, [verse?.number]);
if (!verse) return null;
const copy = () => {
navigator.clipboard?.writeText(`${bookName} ${chapter}:${verse.number} ${translation} — ${verse.text}`);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
};
const actionBtn = 'rounded-lg p-1.5 hover:bg-black/5';
return (
<Sheet
open modal={false} pane onClose={onClose} panelRef={panelRef} edge={resizeHandle}
title={`${bookName} ${chapter}:${verse.number}`}
headerRight={(
<>
<button type="button" onClick={onToggleBookmark} className={`${actionBtn} ${bookmarkColor ? 'text-[var(--r-accent)]' : ''}`} aria-label={bookmarkColor ? 'Remove bookmark' : 'Bookmark verse'}>
<BookmarkIcon filled={!!bookmarkColor} />
</button>
{bookmarkColor && (
<button type="button" onClick={onCycleColor} className={actionBtn} aria-label="Change bookmark colour">
<span className="block h-4 w-4 rounded-full border border-black/10" style={{ backgroundColor: bookmarkColor }} />
</button>
)}
{onPray && (
<button type="button" onClick={() => onPray(`${bookName} ${chapter}:${verse.number}`)} className={`${actionBtn} text-xs`} aria-label="Pray about this verse" title="Add a prayer about this verse">
Pray
</button>
)}
<button type="button" onClick={() => setImageOpen((o) => !o)} aria-pressed={imageOpen} className={`${actionBtn} text-xs`} aria-label="Share verse as image" title="Share as image">
Image
</button>
<button type="button" onClick={copy} className={actionBtn} aria-label="Copy verse">
{copied ? <span className="px-1 text-xs">Copied</span> : <CopyIcon />}
</button>
</>
)}
>
{imageOpen && (
<div className="mb-3">
<VerseShareImage text={verse.text} reference={`${bookName} ${chapter}:${verse.number}`} translation={translation}
notice={READER_TRANSLATIONS.find((t) => t.id === translation)?.notice ?? ''} onClose={() => setImageOpen(false)} />
</div>
)}
{onHighlightVerse && (
// One-tap highlighting of the whole verse (to mark just some words, select them in the text)
<div className="mb-3 flex flex-wrap items-center gap-1.5" role="group" aria-label="Highlight this verse">
<span className="mr-1 text-xs text-[var(--r-sub)]">Highlight</span>
{HIGHLIGHT_COLORS.map((color) => {
const on = verseHighlights.some((h) => h.color === color && isWholeVerse(h, verse.text));
return (
<button key={color} type="button" onClick={() => onHighlightVerse(color)} aria-pressed={on}
aria-label={`Highlight verse ${colorTitle(color, colorLabels).toLowerCase()}`} data-tip={`${colorTitle(color, colorLabels)}${on ? ' — tap to remove' : ''}`}
className={`h-6 w-6 rounded-full border-2 transition hover:scale-110 ${on ? 'border-[var(--r-text)]' : 'border-transparent ring-1 ring-black/10'}`}
style={{ backgroundColor: color }}>
{on && <span className="text-[10px] leading-none text-[#2b2620]" aria-hidden="true">✓</span>}
</button>
);
})}
{verseHighlights.length > 0 && (
<button type="button" onClick={onClearVerseHighlights} className="ml-1 text-xs text-[var(--r-sub)] underline hover:text-[var(--r-text)]">Clear</button>
)}
<span className="basis-full text-[11px] text-[var(--r-sub)]">Or select words in the text to highlight just those.</span>
</div>
)}
{studiesHere.length > 0 && onOpenStudy && (
<div className="mb-3 rounded-xl border border-[var(--r-line)] p-3" aria-label="In your studies">
<p className="mb-1.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">You’ve studied this</p>
<ul className="space-y-2">
{studiesHere.slice(0, 5).map((e) => (
<li key={`${e.projectId}/${e.chunkId}`} className="flex items-start justify-between gap-3 text-sm">
<span className="min-w-0">
<span className="font-medium">{e.title}</span> <span className="text-xs text-[var(--r-sub)]">{e.ref}</span>
{e.snippet && <span className="mt-0.5 block truncate font-serif text-[13px] text-[var(--r-sub)]">{e.snippet}</span>}
</span>
<button type="button" onClick={() => onOpenStudy(e.projectId, e.chunkId)} className="shrink-0 text-xs font-medium text-[var(--r-accent)] hover:underline">Open</button>
</li>
))}
</ul>
{studiesHere.length > 5 && <p className="mt-1.5 text-[11px] text-[var(--r-sub)]">and {studiesHere.length - 5} more</p>}
</div>
)}
{(bookmarkColor && onSetBookmarkNote) || (verseHighlights.length > 0 && onSetHighlightNote) ? (
<div className="mb-3 space-y-2 rounded-xl border border-[var(--r-line)] p-3" aria-label="Your notes on this verse">
<p className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Your notes</p>
{bookmarkColor && onSetBookmarkNote && (
<div>
<p className="flex items-center gap-1.5 text-xs text-[var(--r-sub)]"><span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: bookmarkColor }} />Bookmark</p>
<MarkNote note={bookmarkNote} label="bookmark note" onSave={onSetBookmarkNote} className="mt-1" />
</div>
)}
{onSetHighlightNote && verseHighlights.map((h) => (
<div key={h.id}>
<p className="flex items-center gap-1.5 truncate text-xs text-[var(--r-sub)]"><span className="h-2.5 w-2.5 shrink-0 rounded-full border border-black/10" style={{ backgroundColor: h.color }} /><span className="truncate">{isWholeVerse(h, verse.text) ? 'Whole verse' : `“${h.text}”`}</span></p>
<MarkNote note={h.note} label="highlight note" onSave={(text) => onSetHighlightNote(h.id, text)} className="mt-1" />
</div>
))}
</div>
) : null}
<div role="tablist" aria-label="About this verse" className="mb-3 flex gap-1.5 overflow-x-auto pb-1">
{TABS.map(([id, label]) => (
<button key={id} type="button" role="tab" aria-selected={tab === id} onClick={() => setTab(id)}
className={`shrink-0 rounded-full border px-3 py-1 text-xs ${tab === 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'}`}>
{label}
</button>
))}
</div>
{tab === 'commentary' && <CommentaryTab book={book} chapter={chapter} verse={verse.number} />}
{tab === 'compare' && <CompareTab book={book} chapter={chapter} verse={verse.number} current={translation} />}
{tab === 'original' && <OriginalTab words={words} />}
{tab === 'refs' && onOpenMap && <button type="button" onClick={onOpenMap} className="mb-3 rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5">Map this chapter’s links (M)</button>}
{tab === 'refs' && (
<RefsTab refs={crossRefs} loading={crossRefsLoading} active={preview} onPick={setPreview}
preview={preview && <RefPreview refItem={preview} translation={translation} onOpen={onJump} onClose={() => setPreview(null)} />} />
)}
{tab === 'people' && <PeoplePlacesTab book={book} chapter={chapter} verse={verse.number} />}
{tab === 'aids' && iqOn && <IqStudyAids book={book} chapter={chapter} verse={verse.number} />}
</Sheet>
);
}