Add cross-reference map, admin health page, passkeys and device list, settings tabs
Build and Push Docker Image / build-and-push (push) Successful in 10m22s

- Reader: chapter cross-reference map (M) from IQ Bible's reference graph
- Admin: Health tab with disk use, snapshots (back up, download, restore), failure logs
- Passkey sign-in (WebAuthn), signed-in device list, sign out others, optional sign-in alerts
- Settings split into Account, Preferences, Notifications, Security, Your data tabs
- ntfy server field: catch a topic typed into the server box

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-06 12:52:49 -04:00
1 parent 207bfcb919
commit bdb04cdedc
35 files changed
+1860 -22

No files matched your search

+89
View File
@@ -0,0 +1,89 @@
// The cross-reference map for a chapter (from IQ Bible's reference data): the chapter in the middle, the chapters
// it is linked to around it, in Bible order. Heavier lines mean more verse-to-verse links. Tap a chapter to see how
// they connect, open it, or move the map's centre there. A list below says the same in words.
import { useEffect, useMemo, useState } from 'react';
import Sheet from './Sheet.jsx';
import { useIq } from '../../hooks/useIq.js';
import { bookOptions } from '../../utils/bibleUtils.jsx';
import { chapterId, layoutMap, linkedChapters, parseGraph } from '../../utils/xrefMap.js';
const SIZE = 700;
const bookName = (abbrev) => bookOptions.find((b) => b.abbrev === abbrev)?.name ?? abbrev;
export default function CrossRefMap({ open, onClose, book, chapter, onOpenChapter }) {
const [center, setCenter] = useState({ book, chapter });
const [depth, setDepth] = useState(1);
const [picked, setPicked] = useState(null);
useEffect(() => { if (open) { setCenter({ book, chapter }); setPicked(null); } }, [open, book, chapter]);
const { status, data, error } = useIq(open ? 'cross-references/graph' : null, { book: center.book, chapter: center.chapter, depth });
const centerId = chapterId(center.book, center.chapter);
const graph = useMemo(() => parseGraph(data), [data]);
const map = useMemo(() => layoutMap(graph, centerId, SIZE), [graph, centerId]);
const linked = useMemo(() => linkedChapters(graph, centerId), [graph, centerId]);
const byId = new Map(map.nodes.map((n) => [n.id, n]));
const sel = picked ? byId.get(picked) : null;
const selLinks = sel ? graph.edges.filter((e) => (e.source === centerId && e.target === sel.id) || (e.target === centerId && e.source === sel.id)) : [];
const label = (n) => `${bookName(n.book)} ${n.chapter}`;
const hot = new Set([...map.nodes].filter((n) => n.id !== centerId).sort((a, b) => b.weight - a.weight).slice(0, 14).map((n) => n.id)); // labelled on the picture
return (
<Sheet open={open} onClose={onClose} title={`Cross-reference map · ${bookName(center.book)} ${center.chapter}`} label="Cross-reference map">
<div className="mb-3 flex flex-wrap items-center gap-3 text-xs text-[var(--r-sub)]">
<span role="group" aria-label="How far to follow the links" className="flex overflow-hidden rounded-full border border-[var(--r-line)]">
{[[1, 'Directly linked'], [2, 'And the ones they link to']].map(([d, name]) => (
<button key={d} type="button" aria-pressed={depth === d} onClick={() => { setDepth(d); setPicked(null); }}
className={`px-3 py-1 ${depth === d ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'hover:bg-black/5'}`}>{name}</button>
))}
</span>
{(center.book !== book || center.chapter !== chapter) && (
<button type="button" onClick={() => { setCenter({ book, chapter }); setPicked(null); }} className="text-[var(--r-accent)] hover:underline">Back to {bookName(book)} {chapter}</button>
)}
<span>Lines are heavier where there are more verse links.</span>
</div>
{status === 'loading' && <p className="text-sm text-[var(--r-sub)]">Drawing the map…</p>}
{status === 'error' && <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{error}</p>}
{status === 'empty' && <p className="text-sm text-[var(--r-sub)]">No cross-references are recorded for this chapter.</p>}
{status === 'ready' && map.nodes.length > 1 && (
<>
<svg viewBox={`0 0 ${SIZE} ${SIZE}`} role="img" aria-label={`Map of ${linked.length} chapters linked to ${bookName(center.book)} ${center.chapter}`} className="mx-auto block w-full max-w-[34rem]">
{map.edges.map((e) => <path key={`${e.source}-${e.target}`} d={e.path} fill="none" stroke="currentColor" strokeWidth={e.width} opacity={picked && (e.source === picked || e.target === picked) ? 0.9 : e.opacity} className="text-[var(--r-text)]" />)}
{map.nodes.map((n) => (
<g key={n.id} onClick={() => setPicked(n.id === picked ? null : n.id)} className="cursor-pointer" role="button" tabIndex={0} aria-label={`${label(n)}, ${n.weight} links`}
onKeyDown={(ev) => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); setPicked(n.id === picked ? null : n.id); } }}>
<circle cx={n.x} cy={n.y} r={n.r + (n.id === picked ? 3 : 0)} fill={n.color} stroke={n.id === picked || n.id === centerId ? 'currentColor' : 'none'} strokeWidth="2" className="text-[var(--r-text)]" />
{(n.id === centerId || n.id === picked || hot.has(n.id)) && (
<text x={n.x} y={n.y + (n.y < SIZE / 2 ? -n.r - 5 : n.r + 14)} textAnchor="middle" fontSize={n.id === centerId ? 18 : 13} fontWeight={n.id === centerId ? 700 : 500} fill="currentColor" className="text-[var(--r-text)]">{label(n)}</text>
)}
</g>
))}
</svg>
{graph.truncated && <p className="mt-1 text-center text-[11px] text-[var(--r-sub)]">A large map: only the first part is shown.</p>}
{sel && (
<div className="mt-3 rounded-xl border border-[var(--r-line)] p-3 text-sm" role="status">
<p className="font-medium">{label(sel)}</p>
<p className="text-xs text-[var(--r-sub)]">{selLinks.length ? `${selLinks.reduce((n, e) => n + e.weight, 0)} verse links with ${bookName(center.book)} ${center.chapter}` : `${sel.ring > 1 ? 'Linked through another chapter' : 'Linked'}`}</p>
<div className="mt-2 flex gap-3 text-xs">
<button type="button" onClick={() => { onOpenChapter(sel.book, sel.chapter); }} className="font-medium text-[var(--r-accent)] hover:underline">Read {label(sel)}</button>
{sel.id !== centerId && <button type="button" onClick={() => { setCenter({ book: sel.book, chapter: sel.chapter }); setPicked(null); }} className="hover:underline">Put it in the middle</button>}
</div>
</div>
)}
<h3 className="mb-1.5 mt-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Most linked to {bookName(center.book)} {center.chapter}</h3>
<ol className="grid gap-x-4 gap-y-1 text-sm sm:grid-cols-2">
{linked.slice(0, 20).map((c) => (
<li key={c.id} className="flex items-baseline justify-between gap-2">
<button type="button" onClick={() => setPicked(c.id)} className="truncate text-left hover:underline">{bookName(c.book)} {c.chapter}</button>
<span className="shrink-0 text-xs text-[var(--r-sub)]">{c.weight}</span>
</li>
))}
</ol>
</>
)}
<p className="mt-4 text-[10px] text-[var(--r-sub)]">Cross-references via IQ Bible.</p>
</Sheet>
);
}
@@ -0,0 +1,69 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect, beforeEach, afterEach } from 'vitest';
import CrossRefMap from './CrossRefMap.jsx';
import { resetIq } from '../../utils/iqbible.js';
let routes;
let asked;
beforeEach(() => {
resetIq();
asked = [];
routes = {};
vi.stubGlobal('fetch', vi.fn((url) => {
asked.push(String(url));
const hit = routes[String(url)];
return Promise.resolve(hit ? { ok: true, status: 200, json: async () => hit } : { ok: false, status: 404, json: async () => ({ error: 'nf' }) });
}));
});
afterEach(() => vi.unstubAllGlobals());
const graph = {
nodes: [
{ id: 'GEN.1', book: 'GEN', name_en: 'Genesis', chapter: 1 }, { id: 'PSA.104', book: 'PSA', name_en: 'Psalms', chapter: 104 },
{ id: 'JHN.1', book: 'JHN', name_en: 'John', chapter: 1 }, { id: 'COL.1', book: 'COL', name_en: 'Colossians', chapter: 1 },
],
edges: [{ source: 'GEN.1', target: 'PSA.104', weight: 4 }, { source: 'GEN.1', target: 'JHN.1', weight: 9 }, { source: 'GEN.1', target: 'COL.1', weight: 3 }],
};
const url = (book, chapter, depth = 1) => `/api/iq/cross-references/graph?book=${book}&chapter=${chapter}&depth=${depth}`;
test('nothing is fetched until the map is opened; then the linked chapters are drawn and listed strongest first', async () => {
const { rerender } = render(<CrossRefMap open={false} onClose={() => {}} book="GEN" chapter={1} onOpenChapter={() => {}} />);
expect(asked).toEqual([]);
routes[url('GEN', 1)] = graph;
rerender(<CrossRefMap open onClose={() => {}} book="GEN" chapter={1} onOpenChapter={() => {}} />);
expect(await screen.findByRole('img', { name: /Map of 3 chapters linked to Genesis 1/ })).toBeInTheDocument();
const items = screen.getAllByRole('listitem').map((li) => li.textContent);
expect(items[0]).toBe('John 19'); // strongest link first
expect(items[1]).toBe('Psalms 1044');
expect(screen.getByRole('button', { name: 'John 1, 9 links' })).toBeInTheDocument();
});
test('picking a chapter shows how strongly it links, and can open it or put it in the middle', async () => {
routes[url('GEN', 1)] = graph;
routes[url('JHN', 1)] = { nodes: [{ id: 'JHN.1', book: 'JHN', name_en: 'John', chapter: 1 }, { id: 'GEN.1', book: 'GEN', name_en: 'Genesis', chapter: 1 }], edges: [{ source: 'JHN.1', target: 'GEN.1', weight: 9 }] };
const onOpenChapter = vi.fn();
const user = userEvent.setup();
render(<CrossRefMap open onClose={() => {}} book="GEN" chapter={1} onOpenChapter={onOpenChapter} />);
await user.click(await screen.findByRole('button', { name: 'John 1, 9 links' }));
expect(screen.getByRole('status')).toHaveTextContent('9 verse links with Genesis 1');
await user.click(screen.getByRole('button', { name: /read john 1/i }));
expect(onOpenChapter).toHaveBeenCalledWith('JHN', 1);
await user.click(screen.getByRole('button', { name: /put it in the middle/i }));
expect(await screen.findByText(/Cross-reference map · John 1/)).toBeInTheDocument();
expect(asked).toContain(url('JHN', 1));
await user.click(screen.getByRole('button', { name: /back to genesis 1/i }));
expect(await screen.findByText(/Cross-reference map · Genesis 1/)).toBeInTheDocument();
});
test('following the links further asks for more, and a chapter with no links says so', async () => {
routes[url('GEN', 1)] = graph;
const user = userEvent.setup();
const { unmount } = render(<CrossRefMap open onClose={() => {}} book="GEN" chapter={1} onOpenChapter={() => {}} />);
await screen.findByRole('img');
await user.click(screen.getByRole('button', { name: 'And the ones they link to' }));
expect(asked).toContain(url('GEN', 1, 2));
unmount();
render(<CrossRefMap open onClose={() => {}} book="OBA" chapter={1} onOpenChapter={() => {}} />);
expect(await screen.findByText(/No cross-references are recorded/)).toBeInTheDocument();
});
+2 -1
View File
@@ -156,7 +156,7 @@ function CompareTab({ book, chapter, verse, current }) {
export default function VerseSheet({
verse, book, bookName, chapter, translation, words, crossRefs, crossRefsLoading, onLoadCrossRefs,
bookmarkColor, onToggleBookmark, onCycleColor, bookmarkNote = '', onSetBookmarkNote, onSetHighlightNote, studiesHere = [], onOpenStudy, onPray, onJump, onClose, request,
bookmarkColor, onToggleBookmark, onCycleColor, bookmarkNote = '', onSetBookmarkNote, onSetHighlightNote, studiesHere = [], onOpenStudy, onPray, onOpenMap, onJump, onClose, request,
verseHighlights = [], colorLabels = {}, onHighlightVerse, onClearVerseHighlights, panelRef, resizeHandle,
}) {
const iqOn = useIqAvailable();
@@ -291,6 +291,7 @@ export default function VerseSheet({
{tab === 'commentary' && <CommentaryTab book={book} chapter={chapter} verse={verse.number} />}
{tab === 'compare' && <CompareTab book={book} chapter={chapter} verse={verse.number} current={translation} />}
{tab === 'original' && <OriginalTab words={words} />}
{tab === 'refs' && onOpenMap && <button type="button" onClick={onOpenMap} className="mb-3 rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5">Map this chapter’s links (M)</button>}
{tab === 'refs' && (
<RefsTab refs={crossRefs} loading={crossRefsLoading} active={preview} onPick={setPreview}
preview={preview && <RefPreview refItem={preview} translation={translation} onOpen={onJump} onClose={() => setPreview(null)} />} />