Files
study-bible-project/src/components/StudyPanel.jsx
T
nmemmertandClaude Sonnet 5.5 f274bc9c8a
Build and Push Docker Image / build-and-push (push) Successful in 7m14s
Harden sync and security; add IQ Bible, templates, password reset, backups
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>
2026-10-06 08:13:36 -04:00

175 lines
8.8 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.
// 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>
);
}