Build and Push Docker Image / build-and-push (push) Successful in 10m22s
- 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>
304 lines
18 KiB
React
304 lines
18 KiB
React
// 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>
|
||
);
|
||
}
|