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

+17
View File
@@ -0,0 +1,17 @@
// The optional "ntfy server" box in Settings is the address of a custom ntfy server. It is easy to put the topic
// name there by mistake, so this checks what was typed and says what to do instead of leaving a server-side error.
// -> { value, error }: value is the address to save ('' means the default, ntfy.sh); error is a plain-language message or ''
export function normalizeNtfyServer(input, topic = '') {
const typed = String(input ?? '').trim();
if (!typed) return { value: '', error: '' };
if (/^https?:\/\/\S+$/i.test(typed)) return { value: typed.replace(/\/+$/, ''), error: '' };
if (typed.toLowerCase() === String(topic).trim().toLowerCase()) {
return { value: '', error: 'That’s your topic name. The topic goes in the Topic box; leave this one empty to use ntfy.sh.' };
}
// an address typed without https://, like ntfy.example.com or 192.168.1.5:8080
if (/^[\w.-]+\.[A-Za-z]{2,}(:\d+)?(\/\S*)?$/.test(typed) || /^\d{1,3}(\.\d{1,3}){3}(:\d+)?$/.test(typed)) {
return { value: `https://${typed.replace(/\/+$/, '')}`, error: '' };
}
return { value: '', error: 'That doesn’t look like a server address. It’s only for your own ntfy server (for example https://ntfy.example.com). Leave it empty to use ntfy.sh.' };
}
+23
View File
@@ -0,0 +1,23 @@
import { test, expect } from 'vitest';
import { normalizeNtfyServer } from './ntfyServer.js';
test('empty means the default server, and a full address is kept (without a trailing slash)', () => {
expect(normalizeNtfyServer('')).toEqual({ value: '', error: '' });
expect(normalizeNtfyServer(' ')).toEqual({ value: '', error: '' });
expect(normalizeNtfyServer('https://ntfy.example.com/')).toEqual({ value: 'https://ntfy.example.com', error: '' });
expect(normalizeNtfyServer('http://192.168.1.5:8080')).toEqual({ value: 'http://192.168.1.5:8080', error: '' });
});
test('an address typed without https:// gets one', () => {
expect(normalizeNtfyServer('ntfy.example.com')).toEqual({ value: 'https://ntfy.example.com', error: '' });
expect(normalizeNtfyServer('ntfy.example.com:8443/')).toEqual({ value: 'https://ntfy.example.com:8443', error: '' });
expect(normalizeNtfyServer('192.168.1.5:8080')).toEqual({ value: 'https://192.168.1.5:8080', error: '' });
});
test('a topic name put in the server box is recognised and explained', () => {
const r = normalizeNtfyServer('bible-study-project', 'Bible-Study-Project');
expect(r.value).toBe('');
expect(r.error).toMatch(/your topic name/);
expect(normalizeNtfyServer('my-topic', 'other').error).toMatch(/doesn’t look like a server address/);
expect(normalizeNtfyServer('two words').error).not.toBe('');
});
+34
View File
@@ -0,0 +1,34 @@
// Passkeys in the browser: ask the device (Face ID, fingerprint, security key) and pass the answer to the server.
import { browserSupportsWebAuthn, startAuthentication, startRegistration } from '@simplewebauthn/browser';
import { passkeyAdd, passkeyAddOptions, passkeySignInOptions, passkeySignInVerify } from '../syncService.js';
// Passkeys only work on https:// (or localhost), and only in browsers that support them.
export function passkeysSupported() {
try { return typeof window !== 'undefined' && window.isSecureContext !== false && browserSupportsWebAuthn(); } catch { return false; }
}
export function passkeyMessage(err) {
const name = err?.name ?? '';
if (name === 'NotAllowedError' || name === 'AbortError') return 'That was cancelled or timed out. Try again.';
if (name === 'InvalidStateError') return 'This device already has a passkey for your account.';
if (name === 'SecurityError') return 'Passkeys need a secure (https) address.';
return err?.message ? `That didn’t work: ${err.message}` : 'That didn’t work. Try again.';
}
/** Resolves { ok, data (the signed-in user) } or { ok: false, error }. */
export async function signInWithPasskey() {
const opts = await passkeySignInOptions();
if (!opts.ok) return opts;
let response;
try { response = await startAuthentication({ optionsJSON: opts.data }); } catch (err) { return { ok: false, error: passkeyMessage(err), cancelled: err?.name === 'NotAllowedError' }; }
return passkeySignInVerify(response);
}
/** Creates a passkey on this device. Resolves { ok, data (the updated list) } or { ok: false, error }. */
export async function addPasskeyHere(name) {
const opts = await passkeyAddOptions();
if (!opts.ok) return opts;
let response;
try { response = await startRegistration({ optionsJSON: opts.data }); } catch (err) { return { ok: false, error: passkeyMessage(err) }; }
return passkeyAdd(response, name);
}
+89
View File
@@ -0,0 +1,89 @@
// The cross-reference map: a chapter in the middle, the chapters it is linked to around it in rings, in Bible order,
// joined by lines that are heavier where there are more verse-to-verse links. Pure layout code (no drawing) so it can be tested.
import { bookOptions } from './bibleUtils.jsx';
import { groupOfBook } from './bookCovers.js';
const ORDER = new Map(bookOptions.map((b, i) => [b.abbrev, i]));
const bookName = (abbrev) => bookOptions.find((b) => b.abbrev === abbrev)?.name ?? abbrev;
export const chapterId = (book, chapter) => `${book}.${chapter}`;
// The API's { nodes: [{ id, book, name_en, chapter }], edges: [{ source, target, weight }] } -> a clean copy
export function parseGraph(data) {
const nodes = (Array.isArray(data?.nodes) ? data.nodes : []).filter((n) => n && typeof n.id === 'string' && n.book && Number.isFinite(Number(n.chapter)))
.map((n) => ({ id: n.id, book: n.book, chapter: Number(n.chapter), name: n.name_en || bookName(n.book) }));
const have = new Set(nodes.map((n) => n.id));
const edges = (Array.isArray(data?.edges) ? data.edges : []).filter((e) => have.has(e?.source) && have.has(e?.target) && e.source !== e.target)
.map((e) => ({ source: e.source, target: e.target, weight: Math.max(1, Number(e.weight) || 1) }));
return { nodes, edges, truncated: !!data?.truncated };
}
const canonical = (a, b) => (ORDER.get(a.book) ?? 99) - (ORDER.get(b.book) ?? 99) || a.chapter - b.chapter;
// How many chapter-to-chapter hops each chapter is from the centre (the centre is 0)
export function ringsFrom(nodes, edges, centerId) {
const next = new Map(nodes.map((n) => [n.id, []]));
for (const e of edges) { next.get(e.source)?.push(e.target); next.get(e.target)?.push(e.source); }
const ring = new Map([[centerId, 0]]);
const queue = [centerId];
while (queue.length) {
const id = queue.shift();
for (const other of next.get(id) ?? []) if (!ring.has(other)) { ring.set(other, ring.get(id) + 1); queue.push(other); }
}
return ring;
}
// -> { nodes: [{ ...node, x, y, ring, weight, color, r }], edges: [{ ...edge, path, width, opacity }], size }
// Positions are in a square of `size` units with the centre in the middle.
export function layoutMap(graph, centerId, size = 700) {
const ring = ringsFrom(graph.nodes, graph.edges, centerId);
const maxRing = Math.max(1, ...ring.values());
const radii = maxRing === 1 ? [0, 0.42] : maxRing === 2 ? [0, 0.24, 0.44] : [0, 0.18, 0.31, 0.44];
const weightOf = new Map(graph.nodes.map((n) => [n.id, 0]));
for (const e of graph.edges) { weightOf.set(e.source, weightOf.get(e.source) + e.weight); weightOf.set(e.target, weightOf.get(e.target) + e.weight); }
const maxWeight = Math.max(1, ...[...weightOf.entries()].filter(([id]) => id !== centerId).map(([, w]) => w));
const byRing = new Map();
for (const n of graph.nodes) {
const k = Math.min(ring.get(n.id) ?? maxRing, maxRing);
if (!byRing.has(k)) byRing.set(k, []);
byRing.get(k).push(n);
}
const placed = new Map();
for (const [k, list] of byRing) {
list.sort(canonical);
list.forEach((n, i) => {
const angle = k === 0 ? 0 : -Math.PI / 2 + (2 * Math.PI * i) / list.length;
const radius = (radii[k] ?? 0.44) * size;
const w = weightOf.get(n.id);
placed.set(n.id, {
...n, ring: k, weight: w, color: groupOfBook(n.book).colors[0],
x: size / 2 + Math.cos(angle) * radius, y: size / 2 + Math.sin(angle) * radius,
r: n.id === centerId ? 13 : 3 + 6 * Math.sqrt(w / maxWeight),
});
});
}
const maxEdge = Math.max(1, ...graph.edges.map((e) => e.weight));
const edges = graph.edges.map((e) => {
const a = placed.get(e.source);
const b = placed.get(e.target);
const toCenter = e.source === centerId || e.target === centerId;
// lines to the middle are straight; the others bow toward the middle so they don't cross the rim
const c = toCenter ? null : { x: size / 2 + (a.x + b.x - size) * 0.18, y: size / 2 + (a.y + b.y - size) * 0.18 };
return {
...e, path: c ? `M${a.x.toFixed(1)},${a.y.toFixed(1)} Q${c.x.toFixed(1)},${c.y.toFixed(1)} ${b.x.toFixed(1)},${b.y.toFixed(1)}` : `M${a.x.toFixed(1)},${a.y.toFixed(1)} L${b.x.toFixed(1)},${b.y.toFixed(1)}`,
width: 0.6 + 2.4 * (e.weight / maxEdge), opacity: toCenter ? 0.25 + 0.5 * (e.weight / maxEdge) : 0.08 + 0.3 * (e.weight / maxEdge),
};
});
return { nodes: [...placed.values()], edges, size };
}
// The chapters linked to the centre, strongest first: [{ id, name, chapter, weight }]
export function linkedChapters(graph, centerId) {
const out = new Map();
for (const e of graph.edges) {
const other = e.source === centerId ? e.target : e.target === centerId ? e.source : null;
if (other) out.set(other, (out.get(other) ?? 0) + e.weight);
}
const byId = new Map(graph.nodes.map((n) => [n.id, n]));
return [...out].map(([id, weight]) => ({ ...byId.get(id), weight })).sort((a, b) => b.weight - a.weight || canonical(a, b));
}
+65
View File
@@ -0,0 +1,65 @@
import { test, expect } from 'vitest';
import { chapterId, layoutMap, linkedChapters, parseGraph, ringsFrom } from './xrefMap.js';
const raw = {
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 },
{ id: 'junk' }, null,
],
edges: [
{ source: 'GEN.1', target: 'PSA.104', weight: 4 },
{ source: 'GEN.1', target: 'JHN.1', weight: 9 },
{ source: 'JHN.1', target: 'COL.1', weight: 2 },
{ source: 'GEN.1', target: 'NOPE.9', weight: 1 }, // points at a chapter that isn't in the picture
{ source: 'GEN.1', target: 'GEN.1', weight: 5 }, // a chapter linked to itself
],
};
test('the API\'s graph is cleaned: bad nodes, dangling and self links are dropped', () => {
const g = parseGraph(raw);
expect(g.nodes.map((n) => n.id)).toEqual(['GEN.1', 'PSA.104', 'JHN.1', 'COL.1']);
expect(g.edges).toHaveLength(3);
expect(parseGraph(null)).toEqual({ nodes: [], edges: [], truncated: false });
expect(chapterId('GEN', 1)).toBe('GEN.1');
});
test('chapters are placed in rings by how many links away they are', () => {
const g = parseGraph(raw);
const ring = ringsFrom(g.nodes, g.edges, 'GEN.1');
expect([...ring]).toEqual([['GEN.1', 0], ['PSA.104', 1], ['JHN.1', 1], ['COL.1', 2]]);
});
test('the layout puts the centre in the middle and the rest around it in Bible order', () => {
const g = parseGraph(raw);
const map = layoutMap(g, 'GEN.1', 700);
const at = Object.fromEntries(map.nodes.map((n) => [n.id, n]));
expect(at['GEN.1']).toMatchObject({ x: 350, y: 350, ring: 0 });
expect(at['PSA.104'].ring).toBe(1);
expect(at['COL.1'].ring).toBe(2);
// ring 1 is Psalms then John, starting at the top and going clockwise
expect(at['PSA.104'].y).toBeLessThan(350);
expect(at['JHN.1'].y).toBeGreaterThan(at['PSA.104'].y);
// every point is inside the square
expect(map.nodes.every((n) => n.x >= 0 && n.x <= 700 && n.y >= 0 && n.y <= 700)).toBe(true);
// heavier links are drawn heavier, and every line has a path
const heavy = map.edges.find((e) => e.weight === 9);
const light = map.edges.find((e) => e.weight === 2);
expect(heavy.width).toBeGreaterThan(light.width);
expect(map.edges.every((e) => e.path.startsWith('M'))).toBe(true);
expect(light.path).toContain('Q'); // a link that doesn't touch the middle bows toward it
expect(heavy.path).toContain('L');
});
test('the linked chapters are listed strongest first', () => {
const g = parseGraph(raw);
expect(linkedChapters(g, 'GEN.1').map((c) => [c.id, c.weight])).toEqual([['JHN.1', 9], ['PSA.104', 4]]);
});
test('a map with only the centre, or nothing linked, still lays out', () => {
const lone = { nodes: [{ id: 'GEN.1', book: 'GEN', name: 'Genesis', chapter: 1 }], edges: [] };
expect(layoutMap(lone, 'GEN.1').nodes).toHaveLength(1);
expect(layoutMap({ nodes: [], edges: [] }, 'GEN.1').nodes).toEqual([]);
});