Harden sync and security; add IQ Bible, templates, password reset, backups
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:
nmemmertandClaude Sonnet 5.5 committed 2026-10-06 08:13:36 -04:00
1 parent 6bb392933a
commit f274bc9c8a
67 files changed
+4734 -1569

No files matched your search

+56
View File
@@ -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>
);
}