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
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:
1 parent
207bfcb919
commit
bdb04cdedc
35 files changed
+1860
-22
No files matched your search
@@ -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();
|
||||
});
|
||||
@@ -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)} />} />
|
||||
|
||||
Reference in new issue
Block a user