Harden sync and security; add IQ Bible, templates, password reset, backups
Build and Push Docker Image / build-and-push (push) Successful in 7m14s
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>
This commit is contained in:
1 parent
6bb392933a
commit
f274bc9c8a
67 files changed
+4734
-1569
No files matched your search
@@ -0,0 +1,82 @@
|
||||
// "Forgot password": ask for a code (it is sent to the account's notification topic), then choose a new
|
||||
// password with it. Shown on the sign-in page in place of the sign-in form.
|
||||
import { useState } from 'react';
|
||||
import { requestPasswordReset, resetPassword } from '../syncService.js';
|
||||
|
||||
const input = 'mt-1 block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2.5 text-sm text-[var(--r-text)]';
|
||||
const primary = 'w-full rounded-full bg-[var(--r-accent)] px-4 py-2.5 text-sm font-medium text-[var(--r-on)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50';
|
||||
|
||||
export default function ResetPasswordForm({ initialEmail = '', onDone, onBack }) {
|
||||
const [step, setStep] = useState('ask'); // 'ask' | 'code' | 'done'
|
||||
const [email, setEmail] = useState(initialEmail);
|
||||
const [form, setForm] = useState({ code: '', password: '', token: '' });
|
||||
const [message, setMessage] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const ask = async (e) => {
|
||||
e.preventDefault();
|
||||
setError(''); setBusy(true);
|
||||
const result = await requestPasswordReset(email.trim());
|
||||
setBusy(false);
|
||||
if (!result.ok) { setError(result.error ?? 'Something went wrong.'); return; }
|
||||
setMessage(result.data?.message ?? 'If that account has a notification topic, a code was sent.');
|
||||
setStep('code');
|
||||
};
|
||||
|
||||
const reset = async (e) => {
|
||||
e.preventDefault();
|
||||
setError(''); setBusy(true);
|
||||
const result = await resetPassword({ email: email.trim(), code: form.code, newPassword: form.password, token: form.token.trim() || undefined });
|
||||
setBusy(false);
|
||||
if (!result.ok) { setError(result.error ?? 'Something went wrong.'); return; }
|
||||
setStep('done');
|
||||
};
|
||||
|
||||
if (step === 'done') {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-medium">Password changed</h2>
|
||||
<p className="mt-2 text-sm text-[var(--r-sub)]">You’ve been signed out everywhere. Sign in with your new password.</p>
|
||||
<button type="button" onClick={onDone} className={`mt-6 ${primary}`}>Back to sign in</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-medium">Reset your password</h2>
|
||||
{step === 'ask' ? (
|
||||
<form onSubmit={ask} className="mt-4 space-y-4">
|
||||
<p className="text-sm text-[var(--r-sub)]">We’ll send a one-time code to the notification topic you set up for reading reminders (ntfy).</p>
|
||||
<label className="block text-sm">Email
|
||||
<input type="email" required autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} className={input} />
|
||||
</label>
|
||||
{error && <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{error}</p>}
|
||||
<button type="submit" disabled={busy} className={primary}>{busy ? 'Please wait…' : 'Send me a code'}</button>
|
||||
<button type="button" onClick={() => { setStep('code'); setMessage(''); }} className="block w-full text-center text-sm text-[var(--r-sub)] underline hover:text-[var(--r-text)]">I already have a code</button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={reset} className="mt-4 space-y-4">
|
||||
{message && <p role="status" className="text-sm text-[var(--r-sub)]">{message}</p>}
|
||||
<label className="block text-sm">Email
|
||||
<input type="email" required value={email} onChange={(e) => setEmail(e.target.value)} className={input} />
|
||||
</label>
|
||||
<label className="block text-sm">Code
|
||||
<input type="text" required autoFocus autoComplete="one-time-code" value={form.code} onChange={(e) => setForm((f) => ({ ...f, code: e.target.value }))} className={`${input} text-center font-mono tracking-widest`} placeholder="ABCD2345" />
|
||||
</label>
|
||||
<label className="block text-sm">New password
|
||||
<input type="password" required minLength={8} autoComplete="new-password" value={form.password} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} className={input} />
|
||||
<span className="mt-1 block text-xs text-[var(--r-sub)]">At least 8 characters.</span>
|
||||
</label>
|
||||
<label className="block text-sm">Authenticator code <span className="text-xs text-[var(--r-sub)]">(only if you use two-factor sign-in)</span>
|
||||
<input type="text" inputMode="numeric" value={form.token} onChange={(e) => setForm((f) => ({ ...f, token: e.target.value }))} className={input} placeholder="123456" />
|
||||
</label>
|
||||
{error && <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{error}</p>}
|
||||
<button type="submit" disabled={busy} className={primary}>{busy ? 'Please wait…' : 'Change password'}</button>
|
||||
</form>
|
||||
)}
|
||||
<button type="button" onClick={onBack} className="mt-4 w-full text-center text-sm text-[var(--r-sub)] underline hover:text-[var(--r-text)]">Back to sign in</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, test, expect, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../syncService.js', () => ({ requestPasswordReset: vi.fn(), resetPassword: vi.fn() }));
|
||||
import ResetPasswordForm from './ResetPasswordForm.jsx';
|
||||
import { requestPasswordReset, resetPassword } from '../syncService.js';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
requestPasswordReset.mockResolvedValue({ ok: true, data: { message: 'A code was sent if you have a topic.' } });
|
||||
resetPassword.mockResolvedValue({ ok: true, data: { ok: true } });
|
||||
});
|
||||
|
||||
test('asks for a code, then sets the new password with it', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onDone = vi.fn();
|
||||
render(<ResetPasswordForm initialEmail="me@x.co" onDone={onDone} onBack={() => {}} />);
|
||||
await user.click(screen.getByRole('button', { name: /send me a code/i }));
|
||||
expect(requestPasswordReset).toHaveBeenCalledWith('me@x.co');
|
||||
expect(await screen.findByText(/A code was sent/)).toBeInTheDocument();
|
||||
|
||||
await user.type(screen.getByLabelText(/^Code/), 'ABCD2345');
|
||||
await user.type(screen.getByLabelText(/^New password/), 'brand-new-pass');
|
||||
await user.click(screen.getByRole('button', { name: /change password/i }));
|
||||
expect(resetPassword).toHaveBeenCalledWith({ email: 'me@x.co', code: 'ABCD2345', newPassword: 'brand-new-pass', token: undefined });
|
||||
await user.click(await screen.findByRole('button', { name: /back to sign in/i }));
|
||||
expect(onDone).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('a wrong code shows the server\'s message and stays on the form', async () => {
|
||||
resetPassword.mockResolvedValue({ ok: false, error: 'That code is wrong or has expired. Ask for a new one.' });
|
||||
const user = userEvent.setup();
|
||||
render(<ResetPasswordForm initialEmail="me@x.co" onDone={() => {}} onBack={() => {}} />);
|
||||
await user.click(screen.getByRole('button', { name: /i already have a code/i }));
|
||||
await user.type(screen.getByLabelText(/^Code/), 'WRONG123');
|
||||
await user.type(screen.getByLabelText(/^New password/), 'brand-new-pass');
|
||||
await user.click(screen.getByRole('button', { name: /change password/i }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(/wrong or has expired/);
|
||||
});
|
||||
|
||||
test('an authenticator code is passed along when one is typed', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ResetPasswordForm initialEmail="me@x.co" onDone={() => {}} onBack={() => {}} />);
|
||||
await user.click(screen.getByRole('button', { name: /i already have a code/i }));
|
||||
await user.type(screen.getByLabelText(/^Code/), 'ABCD2345');
|
||||
await user.type(screen.getByLabelText(/^New password/), 'brand-new-pass');
|
||||
await user.type(screen.getByLabelText(/Authenticator code/), '123456');
|
||||
await user.click(screen.getByRole('button', { name: /change password/i }));
|
||||
expect(resetPassword).toHaveBeenCalledWith(expect.objectContaining({ token: '123456' }));
|
||||
});
|
||||
@@ -2,11 +2,31 @@
|
||||
// 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];
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// More commentaries from IQ Bible: Gill and Matthew Henry plus hundreds of church-history sources.
|
||||
// Only sources that actually have notes on the chapter being read are offered.
|
||||
import { useState } from 'react';
|
||||
import { useIq } from '../../hooks/useIq.js';
|
||||
import { listOf, pickIqEntries } from '../../utils/iqbible.js';
|
||||
|
||||
const KEY = 'iq-commentary';
|
||||
const muted = 'text-xs text-[var(--r-sub,#64748b)]';
|
||||
|
||||
export default function IqCommentary({ book, chapter, verse }) {
|
||||
const [chosen, setChosen] = useState(() => { try { return localStorage.getItem(KEY) || 'mhenry'; } catch { return 'mhenry'; } });
|
||||
const sources = useIq(`books/${book}/commentaries`, { chapter });
|
||||
const list = listOf(sources.data);
|
||||
const source = list.find((s) => s.name === chosen) ?? list.find((s) => s.name === 'mhenry') ?? list[0];
|
||||
const notes = useIq(source ? `commentaries/${source.name}/${book}/${chapter}` : null);
|
||||
const entries = pickIqEntries(notes.data?.entries, chapter, verse);
|
||||
|
||||
const pick = (name) => { setChosen(name); try { localStorage.setItem(KEY, name); } catch { /* storage unavailable */ } };
|
||||
const groups = list.reduce((acc, s) => { (acc[s.category || 'Other'] ??= []).push(s); return acc; }, {});
|
||||
|
||||
if (sources.status === 'loading') return <p className={muted}>Loading…</p>;
|
||||
if (sources.status === 'error') return <p className={muted}>{sources.error}</p>;
|
||||
if (!list.length) return <p className={muted}>No IQ Bible commentaries cover this chapter.</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className={muted}>
|
||||
Source ({list.length}){' '}
|
||||
<select value={source?.name ?? ''} onChange={(e) => pick(e.target.value)} className="max-w-[16rem] rounded-lg border border-[var(--r-line,#cbd5e1)] bg-[var(--r-bg,#ffffff)] px-2 py-1 text-xs">
|
||||
{Object.entries(groups).map(([cat, items]) => (
|
||||
<optgroup key={cat} label={cat}>
|
||||
{items.map((s) => <option key={s.name} value={s.name}>{s.author_name}{s.year ? ` (${s.year})` : ''}</option>)}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="mt-2 space-y-2">
|
||||
{notes.status === 'loading' && <p className={muted}>Loading…</p>}
|
||||
{notes.status === 'error' && <p className={muted}>{notes.error}</p>}
|
||||
{(notes.status === 'empty' || (notes.status === 'ready' && entries.length === 0)) && (
|
||||
<p className={muted}>{source?.author_name} has no notes at or before verse {verse} in this chapter.</p>
|
||||
)}
|
||||
{entries.map((e, i) => (
|
||||
<div key={i}>
|
||||
<p className="mb-1 text-[11px] text-[var(--r-sub,#64748b)]">
|
||||
{e.verse !== verse || e.verse_end ? `Notes on verse${e.verse_end && e.verse_end !== e.verse ? `s ${e.verse}–${e.verse_end}` : ` ${e.verse}`}` : ''}
|
||||
{e.source_title ? ` · ${e.source_title}` : ''}
|
||||
</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)]">{e.text}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{source && <p className="mt-2 text-[10px] text-[var(--r-sub,#64748b)]">{source.author_name}{source.year ? `, ${source.year}` : ''} · via IQ Bible{source.license ? ` · ${source.license}` : ''}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Home card: today's verse and Spurgeon's Morning & Evening reading, both from IQ Bible.
|
||||
import { useState } from 'react';
|
||||
import { useApp } from '../../context/AppContext.js';
|
||||
import { useIq, useIqAvailable } from '../../hooks/useIq.js';
|
||||
import { bookOptions } from '../../utils/bibleUtils.jsx';
|
||||
|
||||
export default function IqDailyCard() {
|
||||
const on = useIqAvailable();
|
||||
if (!on) return null;
|
||||
return <Card />;
|
||||
}
|
||||
|
||||
function Card() {
|
||||
const { jumpToReaderVerse, openBibleReader } = useApp();
|
||||
const votd = useIq('bibles/eng_kjv/verse-of-the-day');
|
||||
const dev = useIq('devotionals/today');
|
||||
const [part, setPart] = useState(() => (new Date().getHours() < 15 ? 'morning' : 'evening'));
|
||||
const verse = votd.data?.data;
|
||||
const reading = dev.data?.data?.[part];
|
||||
const name = (abbrev) => bookOptions.find((b) => b.abbrev === abbrev)?.name ?? abbrev;
|
||||
if (!verse && !reading) return null;
|
||||
return (
|
||||
<section className="mb-4 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Daily verse and devotional">
|
||||
{verse && (
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Verse of the day · KJV</p>
|
||||
<p className="mt-1 font-serif text-[15px] leading-relaxed">{verse.text}</p>
|
||||
<button type="button" onClick={() => { jumpToReaderVerse(verse.book_usfm, verse.chapter_number, verse.verse_number); openBibleReader(); }}
|
||||
className="mt-1 text-xs font-medium text-[var(--r-accent)] hover:underline">{name(verse.book_usfm)} {verse.chapter_number}:{verse.verse_number} — read in context</button>
|
||||
</div>
|
||||
)}
|
||||
{reading && (
|
||||
<details className="mt-3 border-t border-[var(--r-line)] pt-3">
|
||||
<summary className="cursor-pointer text-sm font-medium">Spurgeon, {part === 'morning' ? 'Morning' : 'Evening'}: “{reading.heading_text}”</summary>
|
||||
<div className="mt-2 flex gap-1.5">
|
||||
{['morning', 'evening'].map((p) => (
|
||||
<button key={p} type="button" onClick={() => setPart(p)} aria-pressed={part === p}
|
||||
className={`rounded-full border px-2.5 py-0.5 text-[11px] ${part === p ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)]'}`}>{p === 'morning' ? 'Morning' : 'Evening'}</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-[var(--r-sub)]">{reading.heading_citation}</p>
|
||||
<div className="mt-2 max-h-72 overflow-y-auto whitespace-pre-line font-serif text-[15px] leading-relaxed">{reading.body}</div>
|
||||
<p className="mt-2 text-[10px] text-[var(--r-sub)]">Charles Spurgeon, Morning and Evening (1866), via IQ Bible.</p>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// A word's entry in the original-language lexicons: Strong's, then Brown-Driver-Briggs (Hebrew) or
|
||||
// Liddell-Scott-Jones and Abbott-Smith (Greek), found through IQ Bible's Strong's crosswalk.
|
||||
import { useState } from 'react';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { useIq } from '../../hooks/useIq.js';
|
||||
import { listOf, normalizeStrongs } from '../../utils/iqbible.js';
|
||||
|
||||
const muted = 'text-xs text-[var(--r-sub,#64748b)]';
|
||||
const SOURCES = { bdb: 'Brown-Driver-Briggs', lsj: 'Liddell-Scott-Jones', 'abbott-smith': 'Abbott-Smith' };
|
||||
const html = (s) => DOMPurify.sanitize(String(s ?? ''), { ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'br', 'p', 'span', 'ul', 'ol', 'li', 'sup', 'sub'], ALLOWED_ATTR: [] });
|
||||
|
||||
function SourceEntry({ lang, source, nativeKey }) {
|
||||
const { status, data, error } = useIq(`lexicon/${lang}/${source}/${encodeURIComponent(nativeKey)}`);
|
||||
const entry = listOf(data)[0];
|
||||
return (
|
||||
<details className="rounded-xl bg-[var(--r-bg,#ffffff)] px-3 py-2">
|
||||
<summary className="cursor-pointer text-sm font-medium">{SOURCES[source] ?? source}</summary>
|
||||
<div className="mt-2 text-[13px] leading-relaxed">
|
||||
{status === 'loading' && <p className={muted}>Loading…</p>}
|
||||
{status === 'error' && <p className={muted}>{error}</p>}
|
||||
{status === 'empty' && <p className={muted}>No entry.</p>}
|
||||
{entry && source === 'bdb' && (
|
||||
<>
|
||||
<p><span className="font-serif text-base" dir="rtl">{entry.head_word}</span> <span className={muted}>{entry.pos}</span></p>
|
||||
{entry.primary_gloss && <p className="font-medium">{entry.primary_gloss}</p>}
|
||||
<div className="whitespace-pre-line" dangerouslySetInnerHTML={{ __html: html(entry.description) }} />
|
||||
</>
|
||||
)}
|
||||
{entry && source !== 'bdb' && (
|
||||
<>
|
||||
<p><span className="font-serif text-base">{entry.greek}</span> <span className={muted}>{entry.transliteration}{entry.morph ? ` · ${entry.morph}` : ''}</span></p>
|
||||
{entry.gloss && <p className="font-medium">{entry.gloss}</p>}
|
||||
<div className="max-h-64 overflow-y-auto" dangerouslySetInnerHTML={{ __html: html(entry.meaning) }} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function Occurrences({ strongs }) {
|
||||
const { status, data, error } = useIq('original-language/search', { strongs, limit: 20 });
|
||||
if (status === 'loading') return <p className={muted}>Loading…</p>;
|
||||
if (status === 'error') return <p className={muted}>{error}</p>;
|
||||
const list = data?.occurrences ?? [];
|
||||
if (!list.length) return <p className={muted}>No occurrences found.</p>;
|
||||
return (
|
||||
<p className="text-[13px] leading-relaxed">
|
||||
{list.map((o) => `${o.name_en} ${o.chapter}:${o.verse}`).join(' · ')}
|
||||
{data.count >= 20 && <span className={muted}> … first 20 shown</span>}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export default function IqLexicon({ strongs: raw, onClose }) {
|
||||
const strongs = normalizeStrongs(raw);
|
||||
const lang = strongs?.startsWith('H') ? 'hebrew' : 'greek';
|
||||
const basic = useIq(strongs ? `lexicon/${lang}/strongs/${strongs}` : null);
|
||||
const cross = useIq(strongs ? `lexicon/crosswalk/${strongs}` : null);
|
||||
const [showOcc, setShowOcc] = useState(false);
|
||||
if (!strongs) return null;
|
||||
const strong = listOf(basic.data)[0];
|
||||
const sources = listOf(cross.data).filter((c) => SOURCES[c.source] && c.native_key && (lang === 'hebrew' ? c.source === 'bdb' : c.source !== 'bdb'));
|
||||
const seen = new Set();
|
||||
const unique = sources.filter((c) => { const k = `${c.source}/${c.native_key}`; if (seen.has(k)) return false; seen.add(k); return true; }).slice(0, 4);
|
||||
|
||||
return (
|
||||
<div className="mt-3 rounded-xl border border-[var(--r-line,#cbd5e1)] p-3" role="region" aria-label={`Lexicon entry for ${strongs}`}>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold">{strongs}{strong?.occurences ? <span className="ml-2 text-xs font-normal text-[var(--r-sub,#64748b)]">{strong.occurences}× in the Bible</span> : null}</p>
|
||||
<button type="button" onClick={onClose} aria-label="Close lexicon entry" className="rounded p-1 text-[var(--r-sub,#64748b)] hover:bg-black/5">×</button>
|
||||
</div>
|
||||
{basic.status === 'loading' && <p className={muted}>Loading…</p>}
|
||||
{basic.status === 'error' && <p className={muted}>{basic.error}</p>}
|
||||
{strong && <p className="mb-2 whitespace-pre-line text-[13px] leading-relaxed">{strong.glossary}</p>}
|
||||
<div className="space-y-1.5">
|
||||
{cross.status === 'loading' && <p className={muted}>Finding other lexicons…</p>}
|
||||
{unique.map((c) => <SourceEntry key={`${c.source}/${c.native_key}`} lang={lang} source={c.source} nativeKey={c.native_key} />)}
|
||||
</div>
|
||||
<button type="button" onClick={() => setShowOcc((o) => !o)} className="mt-2 text-xs font-medium text-[var(--r-accent,#0369a1)] hover:underline">
|
||||
{showOcc ? 'Hide' : 'Where this word appears'}
|
||||
</button>
|
||||
{showOcc && <div className="mt-1"><Occurrences strongs={strongs} /></div>}
|
||||
<p className="mt-2 text-[10px] text-[var(--r-sub,#64748b)]">Lexicons via IQ Bible (Strong's, BDB, LSJ, Abbott-Smith; see credits there).</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// <option>s for IQ Bible's narrated Bibles, to add inside an audio narrator <select>. Playing a
|
||||
// chapter this way uses one of the account's monthly audio streams, so the group says so.
|
||||
import { useIq, useIqAvailable } from '../../hooks/useIq.js';
|
||||
import { listOf } from '../../utils/iqbible.js';
|
||||
|
||||
export default function IqNarratorOptions() {
|
||||
const on = useIqAvailable();
|
||||
const { data } = useIq(on ? 'audio' : null);
|
||||
const sets = listOf(data).filter((s) => s.active !== false);
|
||||
if (!on || sets.length === 0) return null;
|
||||
return (
|
||||
<optgroup label="IQ Bible narrations (uses monthly streams)">
|
||||
{sets.map((s) => <option key={s.audio_id} value={`iq:${s.audio_id}`}>{s.audio_id}{s.narrator ? ` · ${s.narrator}` : ''}</option>)}
|
||||
</optgroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Calendar (.ics) and spreadsheet (.csv) downloads of a reading plan, generated by IQ Bible so the
|
||||
// readings show up as daily all-day events in any calendar app.
|
||||
import { useIqAvailable } from '../../hooks/useIq.js';
|
||||
import { iqUrl } from '../../utils/iqbible.js';
|
||||
|
||||
const link = 'text-[var(--r-sub)] hover:text-[var(--r-text)] underline-offset-2 hover:underline';
|
||||
|
||||
export default function IqPlanExport({ plan, mcheyne = false }) {
|
||||
const on = useIqAvailable();
|
||||
if (!on) return null;
|
||||
const query = mcheyne
|
||||
? { plan: 'mcheyne' }
|
||||
: Array.isArray(plan?.books) && plan.books.length > 0 ? { days: plan.days, books: plan.books.join(',') } : null;
|
||||
if (!query) return null;
|
||||
const href = (format) => iqUrl('bibles/eng_kjv/reading-plan', { ...query, format });
|
||||
return (
|
||||
<p className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[var(--r-sub)]">
|
||||
<span>{mcheyne ? 'M’Cheyne’s 365-day calendar' : 'Take this plan with you'}:</span>
|
||||
<a href={href('ical')} download className={link}>Add to calendar (.ics)</a>
|
||||
<a href={href('csv')} download className={link}>Spreadsheet (.csv)</a>
|
||||
{!mcheyne && <span>Starts today, in the order of the Bible.</span>}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// A verse's "study aids" from IQ Bible: chapter overview, topics, places, people, textual notes, Gospel
|
||||
// parallels and timeline. Each section loads only when opened, so nothing is spent on what isn't read.
|
||||
import { useState } from 'react';
|
||||
import { useIq } from '../../hooks/useIq.js';
|
||||
import { listOf } from '../../utils/iqbible.js';
|
||||
|
||||
const muted = 'text-xs text-[var(--r-sub,#64748b)]';
|
||||
const GOSPELS = ['MAT', 'MRK', 'LUK', 'JHN'];
|
||||
|
||||
function Aid({ title, path, query, children, empty = 'Nothing recorded here.' }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { status, data, error } = useIq(open ? path : null, query);
|
||||
return (
|
||||
<details onToggle={(e) => setOpen(e.currentTarget.open)} className="rounded-xl bg-[var(--r-bg,#ffffff)] px-3 py-2">
|
||||
<summary className="cursor-pointer text-sm font-medium">{title}</summary>
|
||||
<div className="mt-2 text-[13px] leading-relaxed">
|
||||
{status === 'loading' && <p className={muted}>Loading…</p>}
|
||||
{status === 'error' && <p className={muted}>{error}</p>}
|
||||
{status === 'empty' && <p className={muted}>{empty}</p>}
|
||||
{status === 'ready' && children(data)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonProfile({ ustrong }) {
|
||||
const { status, data } = useIq(`bible-characters/by-ustrong/${ustrong}`);
|
||||
const p = data?.data;
|
||||
if (status === 'loading') return <p className={muted}>Loading…</p>;
|
||||
if (!p) return <p className={muted}>No profile.</p>;
|
||||
return (
|
||||
<div className="mt-1 space-y-1">
|
||||
<p>{p.brief || p.briefest || p.description}</p>
|
||||
{p.tribe_nation && <p className={muted}>{p.tribe_nation}</p>}
|
||||
{p.first_appearance && <p className={muted}>First appears {p.first_appearance.book} {p.first_appearance.chapter}:{p.first_appearance.verse}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function People({ data, verse }) {
|
||||
const [open, setOpen] = useState(null);
|
||||
const people = listOf(data);
|
||||
return (
|
||||
<ul className="space-y-1">
|
||||
{people.map((p) => (
|
||||
<li key={p.ustrong}>
|
||||
<button type="button" onClick={() => setOpen(open === p.ustrong ? null : p.ustrong)} className="text-left font-medium">
|
||||
{p.name} <span className={muted}>{p.first_verse === verse ? 'this verse' : `v${p.first_verse}`} {open === p.ustrong ? '▾' : '▸'}</span>
|
||||
</button>
|
||||
{open === p.ustrong && <PersonProfile ustrong={p.ustrong} />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function Places({ data }) {
|
||||
const places = listOf(data);
|
||||
return (
|
||||
<ul className="space-y-1.5">
|
||||
{places.map((p) => (
|
||||
<li key={p.id} className="flex items-baseline justify-between gap-2">
|
||||
<span>{p.name}<span className={muted}> {p.place_type}{p.modern_name && p.modern_name !== p.name ? ` · today: ${p.modern_name}` : ''}{p.special ? ` · ${p.special.replace(/_/g, ' ')}` : ''}</span></span>
|
||||
{p.lat != null && p.lon != null && (
|
||||
<a href={`https://www.openstreetmap.org/?mlat=${p.lat}&mlon=${p.lon}#map=8/${p.lat}/${p.lon}`} target="_blank" rel="noopener noreferrer" className="shrink-0 text-xs font-semibold text-sky-600 hover:underline">Map ↗</a>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function Variants({ data }) {
|
||||
const items = listOf(data);
|
||||
return (
|
||||
<ul className="space-y-3">
|
||||
{items.map((v, i) => (
|
||||
<li key={v.slug ?? i}>
|
||||
<p className="font-medium">{v.title}</p>
|
||||
{v.traditional_reading && <p><span className={muted}>Traditional (KJV/TR): </span>{v.traditional_reading}</p>}
|
||||
{v.critical_reading && <p><span className={muted}>Critical text: </span>{v.critical_reading}</p>}
|
||||
{v.explanation && <p className="mt-1">{v.explanation}</p>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function Harmony({ data, book, chapter }) {
|
||||
const items = listOf(data);
|
||||
return (
|
||||
<ul className="space-y-2">
|
||||
{items.map((s) => (
|
||||
<li key={s.number}>
|
||||
<p className="font-medium">§{s.number} {String(s.title ?? '').toLowerCase().replace(/(^|[.\s])([a-z])/g, (m, a, b) => a + b.toUpperCase())}</p>
|
||||
<p className={muted}>{(s.references ?? []).map((r) => r.citation ?? r).join(' · ')}</p>
|
||||
</li>
|
||||
))}
|
||||
<li className={muted} aria-hidden="true">{book} {chapter}</li>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export default function IqStudyAids({ book, chapter, verse }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Aid title="About this chapter" path={`books/${book}/chapters/${chapter}/info`} empty="No chapter overview is available.">
|
||||
{(d) => (
|
||||
<>
|
||||
<p className="whitespace-pre-line">{d.summary}</p>
|
||||
{d.source_author && <p className={`mt-1 ${muted}`}>{d.source_author}{d.source_year ? `, ${d.source_year}` : ''}</p>}
|
||||
</>
|
||||
)}
|
||||
</Aid>
|
||||
<Aid title="Topics on this verse" path={`topics/${book}/${chapter}/${verse}`} empty="No topics are tagged on this verse.">
|
||||
{(d) => (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{listOf(d).slice(0, 40).map((t, i) => (
|
||||
<span key={i} className="rounded-full border border-[var(--r-line,#cbd5e1)] px-2 py-0.5 text-xs" title={t.label || t.edition}>{t.name}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Aid>
|
||||
<Aid title="Places on this verse" path={`geo/${book}/${chapter}/${verse}`} empty="No places are mentioned on this verse.">{(d) => <Places data={d} />}</Aid>
|
||||
<Aid title="People in this chapter" path={`bible-people/${book}/${chapter}`} empty="No named people in this chapter.">{(d) => <People data={d} verse={verse} />}</Aid>
|
||||
<Aid title="Textual notes" path={`textual-variants/${book}/${chapter}/${verse}`} empty="No known manuscript differences on this verse.">{(d) => <Variants data={d} />}</Aid>
|
||||
{GOSPELS.includes(book) && (
|
||||
<Aid title="Parallel accounts in the Gospels" path="harmony" query={{ book, chapter }} empty="No Gospel parallels recorded for this chapter.">{(d) => <Harmony data={d} book={book} chapter={chapter} />}</Aid>
|
||||
)}
|
||||
<Aid title="Where this sits in the timeline" path={`chronology/for/${book}/${chapter}`}>
|
||||
{(d) => (
|
||||
<>
|
||||
<p>{d.placement ?? `${d.date ?? ''}`}</p>
|
||||
{d.preceding?.length > 0 && <p className={muted}>Before: {d.preceding.map((e) => `${e.event} (${e.date})`).join('; ')}</p>}
|
||||
{d.following?.length > 0 && <p className={muted}>After: {d.following.map((e) => `${e.event} (${e.date})`).join('; ')}</p>}
|
||||
</>
|
||||
)}
|
||||
</Aid>
|
||||
<p className="pt-1 text-[10px] text-[var(--r-sub,#64748b)]">Study aids via IQ Bible.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, describe, test, expect, beforeEach, afterEach } from 'vitest';
|
||||
import IqCommentary from './IqCommentary.jsx';
|
||||
import IqLexicon from './IqLexicon.jsx';
|
||||
import IqStudyAids from './IqStudyAids.jsx';
|
||||
import IqPlanExport from './IqPlanExport.jsx';
|
||||
import { resetIq } from '../../utils/iqbible.js';
|
||||
|
||||
// Answers /api/iq/... from a table of path -> JSON (or { status }), and records what was asked.
|
||||
let routes;
|
||||
let asked;
|
||||
const reply = (body, status = 200) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(body), text: () => Promise.resolve('') });
|
||||
|
||||
beforeEach(() => {
|
||||
resetIq();
|
||||
asked = [];
|
||||
routes = { '/api/iq/status': { available: true } };
|
||||
vi.stubGlobal('fetch', vi.fn((url) => {
|
||||
const path = String(url);
|
||||
asked.push(path);
|
||||
const hit = routes[path] ?? routes[path.split('?')[0]];
|
||||
return hit === undefined ? reply({ error: 'not found' }, 404) : hit.status ? reply(hit.body ?? { error: 'x' }, hit.status) : reply(hit);
|
||||
}));
|
||||
localStorage.clear();
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe('IqCommentary', () => {
|
||||
const sources = { data: [
|
||||
{ name: 'mhenry', author_name: 'Matthew Henry', category: 'Puritan', year: '1706' },
|
||||
{ name: 'thomas-aquinas', author_name: 'Thomas Aquinas', category: 'Western & Medieval Theology', year: '1274' },
|
||||
] };
|
||||
|
||||
test('offers the commentators on this chapter and shows their notes for the verse', async () => {
|
||||
routes['/api/iq/books/JHN/commentaries?chapter=3'] = sources;
|
||||
routes['/api/iq/commentaries/mhenry/JHN/3'] = { entries: [
|
||||
{ chapter: 3, verse: 0, text: 'intro' },
|
||||
{ chapter: 3, verse: 14, verse_end: 21, text: 'God so loved the world.' },
|
||||
] };
|
||||
render(<IqCommentary book="JHN" chapter={3} verse={16} />);
|
||||
expect(await screen.findByText('God so loved the world.')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Notes on verses 14–21/)).toBeInTheDocument();
|
||||
const options = within(screen.getByRole('combobox')).getAllByRole('option').map((o) => o.textContent);
|
||||
expect(options).toEqual(['Matthew Henry (1706)', 'Thomas Aquinas (1274)']);
|
||||
});
|
||||
|
||||
test('choosing another commentator loads theirs and remembers the choice', async () => {
|
||||
routes['/api/iq/books/JHN/commentaries?chapter=3'] = sources;
|
||||
routes['/api/iq/commentaries/mhenry/JHN/3'] = { entries: [{ chapter: 3, verse: 16, text: 'Henry on 16' }] };
|
||||
routes['/api/iq/commentaries/thomas-aquinas/JHN/3'] = { entries: [{ chapter: 3, verse: 16, text: 'Aquinas on 16' }] };
|
||||
render(<IqCommentary book="JHN" chapter={3} verse={16} />);
|
||||
await screen.findByText('Henry on 16');
|
||||
await userEvent.setup().selectOptions(screen.getByRole('combobox'), 'thomas-aquinas');
|
||||
expect(await screen.findByText('Aquinas on 16')).toBeInTheDocument();
|
||||
expect(localStorage.getItem('iq-commentary')).toBe('thomas-aquinas');
|
||||
});
|
||||
|
||||
test('says so when nobody wrote on the chapter', async () => {
|
||||
routes['/api/iq/books/JHN/commentaries?chapter=3'] = { data: [] };
|
||||
render(<IqCommentary book="JHN" chapter={3} verse={16} />);
|
||||
expect(await screen.findByText(/No IQ Bible commentaries cover this chapter/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('IqLexicon', () => {
|
||||
test('shows Strong\'s and the Greek lexicons the crosswalk points to', async () => {
|
||||
routes['/api/iq/lexicon/greek/strongs/G26'] = { data: [{ glossary: 'agape: love', occurences: 116 }] };
|
||||
routes['/api/iq/lexicon/crosswalk/G26'] = { data: [
|
||||
{ strongs_id: 'G26', source: 'lsj', native_key: 'G0026' },
|
||||
{ strongs_id: 'G26', source: 'abbott-smith', native_key: 'G0026' },
|
||||
] };
|
||||
routes['/api/iq/lexicon/greek/lsj/G0026'] = { data: [{ greek: 'ἀγάπη', transliteration: 'agapē', gloss: 'love', meaning: '<b>love</b><script>alert(1)</script>' }] };
|
||||
const { container } = render(<IqLexicon strongs="G0026" onClose={() => {}} />);
|
||||
expect(await screen.findByText(/agape: love/)).toBeInTheDocument();
|
||||
expect(screen.getByText('116× in the Bible')).toBeInTheDocument();
|
||||
await userEvent.setup().click(await screen.findByText('Liddell-Scott-Jones'));
|
||||
await screen.findByText('love', { selector: 'p.font-medium' });
|
||||
expect(container.querySelector('script')).toBeNull(); // lexicon HTML is sanitised
|
||||
expect(screen.getByText('Abbott-Smith')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Hebrew words use BDB', async () => {
|
||||
routes['/api/iq/lexicon/hebrew/strongs/H430'] = { data: [{ glossary: 'elohim' }] };
|
||||
routes['/api/iq/lexicon/crosswalk/H430'] = { data: [{ source: 'bdb', native_key: 'BDB65' }, { source: 'lsj', native_key: 'x' }] };
|
||||
render(<IqLexicon strongs="H430" onClose={() => {}} />);
|
||||
expect(await screen.findByText('Brown-Driver-Briggs')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Liddell-Scott-Jones')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('IqStudyAids', () => {
|
||||
test('each section loads only when opened', async () => {
|
||||
routes['/api/iq/topics/JHN/3/16'] = { data: [{ name: 'faith', edition: 'iqbible' }, { name: 'eternal life', edition: 'nave-torrey' }] };
|
||||
render(<IqStudyAids book="JHN" chapter={3} verse={16} />);
|
||||
expect(asked.filter((u) => u.startsWith('/api/iq/') && !u.includes('status'))).toEqual([]);
|
||||
await userEvent.setup().click(screen.getByText('Topics on this verse'));
|
||||
expect(await screen.findByText('faith')).toBeInTheDocument();
|
||||
expect(screen.getByText('eternal life')).toBeInTheDocument();
|
||||
expect(asked.some((u) => u.includes('/geo/'))).toBe(false);
|
||||
});
|
||||
|
||||
test('a section with nothing says so, and the Gospel parallels appear only in the Gospels', async () => {
|
||||
const { rerender } = render(<IqStudyAids book="ROM" chapter={8} verse={1} />);
|
||||
expect(screen.queryByText('Parallel accounts in the Gospels')).toBeNull();
|
||||
await userEvent.setup().click(screen.getByText('Textual notes'));
|
||||
expect(await screen.findByText(/No known manuscript differences/)).toBeInTheDocument();
|
||||
rerender(<IqStudyAids book="MAT" chapter={14} verse={1} />);
|
||||
expect(screen.getByText('Parallel accounts in the Gospels')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('places link to a map when they have coordinates', async () => {
|
||||
routes['/api/iq/geo/2KI/5/12'] = { data: [{ id: 1, name: 'Abana', place_type: 'river', modern_name: 'Barada River', lat: 33.5, lon: 36.3 }, { id: 2, name: 'Somewhere', place_type: 'region', special: 'unknown_place' }] };
|
||||
render(<IqStudyAids book="2KI" chapter={5} verse={12} />);
|
||||
await userEvent.setup().click(screen.getByText('Places on this verse'));
|
||||
const link = await screen.findByRole('link', { name: /Map/ });
|
||||
expect(link.href).toContain('mlat=33.5');
|
||||
expect(screen.getAllByRole('link')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('IqPlanExport', () => {
|
||||
test('links a plan to calendar and spreadsheet files, only when IQ Bible is on', async () => {
|
||||
const plan = { id: 'p', days: 30, books: ['MAT', 'MRK'] };
|
||||
const { container, rerender } = render(<IqPlanExport plan={plan} />);
|
||||
await waitFor(() => expect(screen.getByRole('link', { name: /calendar/i })).toBeInTheDocument());
|
||||
const href = screen.getByRole('link', { name: /calendar/i }).getAttribute('href');
|
||||
expect(href).toContain('/api/iq/bibles/eng_kjv/reading-plan?');
|
||||
expect(href).toContain('format=ical');
|
||||
expect(href).toContain('days=30');
|
||||
rerender(<IqPlanExport plan={{ id: 'q', days: 5 }} />); // a plan with no book list can't be exported
|
||||
expect(container.querySelector('a')).toBeNull();
|
||||
});
|
||||
|
||||
test('shows nothing without a key on the server', async () => {
|
||||
routes['/api/iq/status'] = { available: false };
|
||||
const { container } = render(<IqPlanExport plan={{ days: 30, books: ['MAT'] }} />);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { formatDayReadings, getPlanSchedule, daysBehindPace, nextUnreadDayIndex
|
||||
import { useOfflineSave } from '../../hooks/useOfflineSave.js';
|
||||
import { DownloadProgress } from '../OfflineSave.jsx';
|
||||
import { canSaveOffline } from '../../utils/offline.js';
|
||||
import IqPlanExport from '../iq/IqPlanExport.jsx';
|
||||
|
||||
const btn = 'rounded-xl border border-[var(--r-line)] px-3 py-2 text-sm hover:bg-black/5';
|
||||
const primary = 'w-full rounded-xl bg-[var(--r-accent)] py-2.5 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50';
|
||||
@@ -34,10 +35,13 @@ function ActiveBody({ plan, progress, onClose, actions }) {
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-3 h-1.5 overflow-hidden rounded-full bg-[var(--r-line)]"><div className="h-full bg-[var(--r-accent)]" style={{ width: `${pct}%` }} /></div>
|
||||
{behind >= 2 && (
|
||||
<div className="mb-3 flex items-center justify-between rounded-xl border border-[var(--r-line)] px-3 py-2 text-xs">
|
||||
<span className="text-[var(--r-sub)]">{behind} days behind pace</span>
|
||||
<button type="button" onClick={() => actions.recalibrate(progress.id)} className="font-medium text-[var(--r-accent)] hover:underline">Catch me up</button>
|
||||
{behind >= 1 && (
|
||||
<div className="mb-3 rounded-xl border border-[var(--r-line)] px-3 py-2 text-xs">
|
||||
<p className="text-[var(--r-sub)]">{behind} {behind === 1 ? 'day' : 'days'} behind pace. Catch up without losing the days you've read:</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-3">
|
||||
<button type="button" onClick={() => actions.recalibrate(progress.id)} className="font-medium text-[var(--r-accent)] hover:underline">Catch me up (pace starts today)</button>
|
||||
<button type="button" onClick={() => { actions.restart(progress.id); onClose(); }} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">Redo the plan from Day 1</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-[38vh] overflow-y-auto rounded-xl border border-[var(--r-line)]">
|
||||
@@ -64,6 +68,7 @@ function ActiveBody({ plan, progress, onClose, actions }) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3"><IqPlanExport plan={plan} /></div>
|
||||
<div className="mt-4 flex items-center justify-between text-xs">
|
||||
<button type="button" onClick={() => { actions.restart(progress.id); onClose(); }} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">Restart</button>
|
||||
<button type="button" onClick={() => { actions.remove(progress.id); onClose(); }} className="text-[var(--r-danger,#b42318)] hover:underline">Remove</button>
|
||||
@@ -109,6 +114,7 @@ function CatalogBody({ plan, active, completedCount, onClose, actions }) {
|
||||
: <button type="button" className={primary} onClick={() => { actions.start(plan.id, translation); onClose(); }}>{completedCount > 0 ? 'Start again' : 'Start plan'}</button>}
|
||||
</div>
|
||||
|
||||
<div className="mt-3"><IqPlanExport plan={plan} /></div>
|
||||
{plan.custom && (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3 border-t border-[var(--r-line)] pt-3 text-xs">
|
||||
<button type="button" onClick={() => { onClose(); actions.edit(plan.id); }} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">Edit plan</button>
|
||||
|
||||
@@ -9,12 +9,18 @@ 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 { useIqAvailable } from '../../hooks/useIq.js';
|
||||
import IqLexicon from '../iq/IqLexicon.jsx';
|
||||
import IqStudyAids from '../iq/IqStudyAids.jsx';
|
||||
|
||||
const TABS = [['commentary', 'Commentary'], ['compare', 'Compare'], ['original', 'Original'], ['refs', 'Cross-refs'], ['people', 'People & places']];
|
||||
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');
|
||||
@@ -31,10 +37,16 @@ function OriginalTab({ words }) {
|
||||
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)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -144,6 +156,8 @@ export default function VerseSheet({
|
||||
bookmarkColor, onToggleBookmark, onCycleColor, 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 [preview, setPreview] = useState(null); // the cross-reference being read in the panel
|
||||
@@ -230,6 +244,7 @@ export default function VerseSheet({
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -72,11 +72,14 @@ export default function DataSection() {
|
||||
<section id="backup" aria-label="Your data" className="scroll-mt-16 space-y-5 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6">
|
||||
<div>
|
||||
<h2 className="text-base font-medium">Your data</h2>
|
||||
<p className="mt-1 text-sm text-[var(--r-sub)]">Everything you’ve made — studies and notes, bookmarks, highlights, ink and reading plans — in one file you keep.</p>
|
||||
<p className="mt-1 text-sm text-[var(--r-sub)]">Everything you’ve made — studies and notes, bookmarks, highlights, ink and reading plans — in one file you keep.{authUser ? ' “Download backup” is what’s on this device; “Download from my account” is everything the server holds. The server also keeps a daily snapshot for the administrator.' : ''}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button type="button" onClick={download} className={btn}>Download backup</button>
|
||||
{authUser && (
|
||||
<a href="/api/account/export" download className={`${btn} inline-block`} title="Everything saved to your account on the server, including studies from other devices">Download from my account</a>
|
||||
)}
|
||||
<button type="button" onClick={() => fileRef.current?.click()} className={btn}>Restore from a backup…</button>
|
||||
<input ref={fileRef} type="file" accept="application/json,.json" onChange={choose} className="hidden" aria-label="Backup file" />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Observe / Interpret / Apply as an accordion (one open at a time), plus a "More"
|
||||
// area for background notes and tags so the page stays quiet.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { templatePrompt } from '../../utils/studyTemplates.js';
|
||||
|
||||
const SECTIONS = [
|
||||
{ field: 'observation', label: 'Observe', hint: 'What does the text say?',
|
||||
@@ -14,7 +15,7 @@ const SECTIONS = [
|
||||
const areaCls = 'w-full resize-y rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3.5 py-3 font-serif text-[15px] leading-7 text-[var(--r-text)] placeholder:font-sans placeholder:text-[13px] placeholder:leading-5 placeholder:text-[var(--r-sub)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
|
||||
const words = (s) => (s ?? '').trim().split(/\s+/).filter(Boolean).length;
|
||||
|
||||
export default function NotesPane({ chunk, updateChunk, allTags }) {
|
||||
export default function NotesPane({ chunk, updateChunk, allTags, templateId }) {
|
||||
const [open, setOpen] = useState(() => {
|
||||
try { return localStorage.getItem('studyNotesOpen') || 'observation'; } catch { return 'observation'; }
|
||||
});
|
||||
@@ -50,7 +51,7 @@ export default function NotesPane({ chunk, updateChunk, allTags }) {
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="px-4 pb-4">
|
||||
<textarea aria-label={label} value={chunk[field] ?? ''} rows={7} placeholder={placeholder}
|
||||
<textarea aria-label={label} value={chunk[field] ?? ''} rows={7} placeholder={templatePrompt(templateId, field) ?? placeholder}
|
||||
onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })} className={areaCls} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -17,7 +17,7 @@ const THEME_ORDER = ['paper', 'sepia', 'dark'];
|
||||
export default function StudioShell({ stage, loading = false, fixedHeight = false, children }) {
|
||||
const {
|
||||
goHome, setCurrentPage, project, allChunks,
|
||||
saveStatus, syncStatus, retrySync, syncConflict, keepMyVersion, useServerVersion,
|
||||
saveStatus, syncStatus, retrySync, syncConflict, keepMyVersion, useServerVersion, mergeBothVersions,
|
||||
readerTheme, setReaderTheme,
|
||||
} = useApp();
|
||||
const noChunks = allChunks.length === 0;
|
||||
@@ -60,6 +60,7 @@ export default function StudioShell({ stage, loading = false, fixedHeight = fals
|
||||
<span>This study was changed on another device. Your edits here are saved on this device, but not yet synced.</span>
|
||||
<span className="flex gap-2">
|
||||
<button type="button" onClick={keepMyVersion} className="rounded-full bg-[var(--r-text)] px-3 py-1 text-xs font-medium text-[var(--r-bg)] hover:opacity-90">Keep this version</button>
|
||||
<button type="button" onClick={mergeBothVersions} className="rounded-full border border-[var(--r-text)] px-3 py-1 text-xs font-medium hover:bg-black/5" title="Keeps every note from both versions, chunk by chunk">Combine both</button>
|
||||
<button type="button" onClick={useServerVersion} className="rounded-full border border-[var(--r-text)] px-3 py-1 text-xs font-medium hover:bg-black/5">Use the other version</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user