Build and Push Docker Image / build-and-push (push) Failing after 19s
Bookmarks and text highlights are saved per user and merged across devices (union on first contact, newer server copy when this device is clean). Theme, default translation, verse layout, commentary source and reading position sync too, each with its own timestamp so the newest change to each setting wins. The Reader never moves under you while it is open. Signing out clears this device's marks and forgets sync timestamps. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
155 lines
7.5 KiB
React
155 lines
7.5 KiB
React
// Inline study panel shown under a verse in the Reader: commentary from several
|
||
// public-domain / open sources, plus the people, places and events tied to the verse.
|
||
import { useState } from 'react';
|
||
import { useApp } from '../context/AppContext.js';
|
||
import {
|
||
COMMENTARIES, loadCommentary, loadTheographic, loadPerson, pickCommentaryEntry, useAsync,
|
||
} from '../hooks/useVerseStudy.js';
|
||
|
||
export function CommentaryTab({ book, chapter, verse }) {
|
||
// The chosen commentary is a synced preference, shared with the Study stage
|
||
const { readerCommentary: sourceId, setReaderCommentary: changeSource } = useApp();
|
||
const source = COMMENTARIES.find((c) => c.id === sourceId) ?? COMMENTARIES[0];
|
||
const { status, data } = useAsync(() => loadCommentary(source.id, book, chapter), [source.id, book, chapter]);
|
||
const entry = pickCommentaryEntry(data, verse);
|
||
|
||
return (
|
||
<div>
|
||
<label className="text-xs text-[var(--r-sub,#64748b)]">
|
||
Commentary{' '}
|
||
<select value={source.id} onChange={(e) => changeSource(e.target.value)}
|
||
className="rounded-lg border border-[var(--r-line,#cbd5e1)] bg-[var(--r-bg,#ffffff)] px-2 py-1 text-xs">
|
||
{COMMENTARIES.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||
</select>
|
||
</label>
|
||
<div className="mt-2">
|
||
{status === 'loading' && <p className="text-xs text-[var(--r-sub,#64748b)]">Loading…</p>}
|
||
{status === 'empty' && <p className="text-xs text-[var(--r-sub,#64748b)]">{source.name} has no notes for this chapter.</p>}
|
||
{status === 'ready' && !entry && <p className="text-xs text-[var(--r-sub,#64748b)]">No notes at or before this verse in {source.name}.</p>}
|
||
{status === 'ready' && entry && (
|
||
<>
|
||
{entry.number !== verse && (
|
||
<p className="mb-1 text-[11px] text-[var(--r-sub,#64748b)]">Notes on the passage starting at verse {entry.number}.</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)]">
|
||
{entry.text}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
<p className="mt-2 text-[10px] text-[var(--r-sub,#64748b)]">{source.credit}</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PersonDetails({ id }) {
|
||
const { status, data } = useAsync(() => loadPerson(id), [id]);
|
||
if (status === 'loading') return <p className="mt-1 text-xs text-[var(--r-sub,#64748b)]">Loading…</p>;
|
||
if (status === 'empty') return <p className="mt-1 text-xs text-[var(--r-sub,#64748b)]">No details available.</p>;
|
||
const era = (y) => `${Math.abs(y)}${y < 0 ? ' BC' : ' AD'}`;
|
||
return (
|
||
<div className="mt-1 space-y-1 text-xs leading-relaxed text-[var(--r-sub,#64748b)]">
|
||
{(data.description ?? []).map((d, i) => <p key={i}>{d}</p>)}
|
||
{!data.description?.length && <p>No description available.</p>}
|
||
{data.minYear != null && (
|
||
<p className="text-[var(--r-sub,#64748b)]">
|
||
Lived approx. {era(data.minYear)}{data.maxYear != null && data.maxYear !== data.minYear ? ` – ${era(data.maxYear)}` : ''}
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function PersonCard({ person }) {
|
||
const [open, setOpen] = useState(false);
|
||
return (
|
||
<li className="rounded-xl bg-[var(--r-bg,#ffffff)] px-3 py-2">
|
||
<button type="button" onClick={() => setOpen((o) => !o)} className="w-full text-left text-sm font-semibold text-[var(--r-text,#334155)]">
|
||
{person.name} <span className="text-xs font-normal text-[var(--r-sub,#64748b)]">{open ? '▾' : '▸'}</span>
|
||
</button>
|
||
{open && <PersonDetails id={person.id} />}
|
||
</li>
|
||
);
|
||
}
|
||
|
||
export function PeoplePlacesTab({ book, chapter, verse }) {
|
||
const { status, data } = useAsync(() => loadTheographic(book, chapter), [book, chapter]);
|
||
if (status === 'loading') return <p className="text-xs text-[var(--r-sub,#64748b)]">Loading…</p>;
|
||
if (status === 'empty') return <p className="text-xs text-[var(--r-sub,#64748b)]">No people or places data for this chapter.</p>;
|
||
const here = (list) => list.filter((x) => x.verses?.includes(verse));
|
||
// Theographic lists some places twice (variant spellings) — collapse by name.
|
||
const places = [...new Map(here(data.places).map((p) => [p.name, p])).values()];
|
||
const people = here(data.people);
|
||
const events = here(data.events);
|
||
if (!people.length && !places.length && !events.length) {
|
||
return <p className="text-xs text-[var(--r-sub,#64748b)]">Nothing tagged on verse {verse}.</p>;
|
||
}
|
||
return (
|
||
<div className="space-y-3">
|
||
{people.length > 0 && (
|
||
<div>
|
||
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-[var(--r-sub,#64748b)]">People</p>
|
||
<ul className="space-y-1">{people.map((p) => <PersonCard key={p.id} person={p} />)}</ul>
|
||
</div>
|
||
)}
|
||
{places.length > 0 && (
|
||
<div>
|
||
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-[var(--r-sub,#64748b)]">Places</p>
|
||
<ul className="space-y-1">
|
||
{places.map((p) => (
|
||
<li key={p.id} className="flex items-center justify-between gap-2 rounded-xl bg-[var(--r-bg,#ffffff)] px-3 py-2 text-sm text-[var(--r-text,#334155)]">
|
||
<span>{p.name}{p.featureType && <span className="ml-1 text-xs text-[var(--r-sub,#64748b)]">{p.featureType}</span>}</span>
|
||
{p.latitude != null && p.longitude != null && (
|
||
<a href={`https://www.openstreetmap.org/?mlat=${p.latitude}&mlon=${p.longitude}#map=8/${p.latitude}/${p.longitude}`}
|
||
target="_blank" rel="noopener noreferrer" className="text-xs font-semibold text-sky-600 hover:underline">
|
||
Map ↗
|
||
</a>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
{events.length > 0 && (
|
||
<div>
|
||
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-[var(--r-sub,#64748b)]">Events</p>
|
||
<ul className="space-y-1">
|
||
{events.map((e) => (
|
||
<li key={e.id} className="rounded-xl bg-[var(--r-bg,#ffffff)] px-3 py-2 text-sm text-[var(--r-text,#334155)]">
|
||
{e.name}{e.startDate && <span className="ml-1 text-xs text-[var(--r-sub,#64748b)]">approx. AD {e.startDate}</span>}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
<p className="text-[10px] text-[var(--r-sub,#64748b)]">
|
||
People, places and events: Theographic Bible Metadata, licensed CC BY-SA 4.0.
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function StudyPanel({ book, chapter, verse, onClose }) {
|
||
const [tab, setTab] = useState('commentary');
|
||
const tabBtn = (id, label) => (
|
||
<button type="button" onClick={() => setTab(id)}
|
||
className={`rounded-lg px-3 py-1 text-xs font-semibold ${tab === id ? 'bg-slate-900 text-white' : 'bg-[var(--r-bg,#ffffff)] text-[var(--r-sub,#64748b)] hover:bg-slate-100'}`}>
|
||
{label}
|
||
</button>
|
||
);
|
||
return (
|
||
<div className="mt-2 mb-1 ml-6 rounded-2xl border border-indigo-100 bg-indigo-50 p-3">
|
||
<div className="mb-2 flex items-center justify-between gap-2">
|
||
<div className="flex gap-1">
|
||
{tabBtn('commentary', 'Commentary')}
|
||
{tabBtn('people', 'People & Places')}
|
||
</div>
|
||
<button type="button" onClick={onClose} className="rounded p-1 text-xs text-[var(--r-sub,#64748b)] hover:bg-[var(--r-bg,#ffffff)]" title="Close">✕</button>
|
||
</div>
|
||
{tab === 'commentary'
|
||
? <CommentaryTab book={book} chapter={chapter} verse={verse} />
|
||
: <PeoplePlacesTab book={book} chapter={chapter} verse={verse} />}
|
||
</div>
|
||
);
|
||
}
|