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,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.' };
|
||||
}
|
||||
@@ -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('');
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
Reference in new issue
Block a user