Build and Push Docker Image / build-and-push (push) Successful in 7m14s
Sync and data safety - Autosave flushes on leaving a study or the tab; storage-full errors are reported - Unsynced studies, deletes and ink are queued and retried when back online - Deletes propagate across devices (with a "Keep it" undo) and no longer resurrect - Server assigns study versions (no client-clock conflicts); "Combine both" merges a conflicted study chunk by chunk - Reading-plan progress made offline is merged, not overwritten; plans, ink and marks are cleared from the device on sign-out Security - Block SSRF through the ntfy reminder URL; failure-only rate limiting per IP and account; constant-time login; TOTP replay protection; input validation; SESSION_SECRET required in production; admin user delete fixed - Forgot password: one-time code sent to the account's ntfy topic Features - IQ Bible proxy (route allow-list, text-only caching per its terms): more commentaries, lexicons, study aids, daily verse and devotional, plan calendar export, narrations, podcast background - Study templates, reading-plan catch-up, account export, daily database snapshots Tests and tooling - ESLint config; App tests split into src/test; tests for sync queues, library hook, merge, templates, IQ Bible UI and reset form Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
175 lines
8.8 KiB
React
175 lines
8.8 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 { useIqAvailable } from '../hooks/useIq.js';
|
||
import IqCommentary from './iq/IqCommentary.jsx';
|
||
import {
|
||
COMMENTARIES, loadCommentary, loadTheographic, loadPerson, pickCommentaryEntry, useAsync,
|
||
} from '../hooks/useVerseStudy.js';
|
||
|
||
export function CommentaryTab({ book, chapter, verse }) {
|
||
const iq = useIqAvailable();
|
||
const [more, setMore] = useState(() => { try { return localStorage.getItem('iq-commentary-mode') === '1'; } catch { return false; } });
|
||
const setMode = (on) => { setMore(on); try { localStorage.setItem('iq-commentary-mode', on ? '1' : '0'); } catch { /* storage unavailable */ } };
|
||
const chip = (on) => `rounded-full border px-2.5 py-0.5 text-[11px] ${on ? 'border-[var(--r-text,#334155)] bg-[var(--r-text,#334155)] text-[var(--r-bg,#ffffff)]' : 'border-[var(--r-line,#cbd5e1)] text-[var(--r-sub,#64748b)]'}`;
|
||
return (
|
||
<div>
|
||
{iq && (
|
||
<div className="mb-2 flex gap-1.5" role="group" aria-label="Commentary library">
|
||
<button type="button" onClick={() => setMode(false)} aria-pressed={!more} className={chip(!more)}>Classic</button>
|
||
<button type="button" onClick={() => setMode(true)} aria-pressed={more} className={chip(more)}>More sources</button>
|
||
</div>
|
||
)}
|
||
{iq && more ? <IqCommentary book={book} chapter={chapter} verse={verse} /> : <ClassicCommentary book={book} chapter={chapter} verse={verse} />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ClassicCommentary({ 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>
|
||
);
|
||
}
|