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

+49 -4
View File
@@ -1,9 +1,12 @@
import { normalizeNtfyServer } from '../utils/ntfyServer.js';
import IqNarratorOptions from '../components/iq/IqNarratorOptions.jsx';
import { useEffect, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import PageShell from '../components/study/PageShell.jsx';
import { READER_TRANSLATIONS } from '../utils/bibleText.js';
import DataSection from '../components/settings/DataSection.jsx';
import PasskeysSection from '../components/settings/PasskeysSection.jsx';
import DevicesSection from '../components/settings/DevicesSection.jsx';
import { COMMENTARIES } from '../hooks/useVerseStudy.js';
import { READER_THEMES } from '../components/reader/readerThemes.js';
import {
@@ -17,6 +20,7 @@ import {
} from '../syncService.js';
import { exportJournalDocx, printJournalPdf } from '../utils/journalExport.js';
const SETTINGS_TABS = [['account', 'Account'], ['prefs', 'Preferences'], ['notify', 'Notifications'], ['security', 'Security'], ['data', 'Your data']];
const FONT_SIZES = [['S', 0.875], ['M', 1], ['L', 1.125], ['XL', 1.25], ['XXL', 1.5]];
const selectCls = 'rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
@@ -61,9 +65,11 @@ export default function SettingsPage() {
} = useApp();
// "Back up my data" from the ⌘K palette lands on that section
const [tab, setTab] = useState(settingsFocus === 'backup' ? 'data' : 'account');
useEffect(() => {
if (!settingsFocus) return;
document.getElementById(settingsFocus)?.scrollIntoView({ block: 'start' });
if (settingsFocus === 'backup') setTab('data');
window.setTimeout(() => document.getElementById(settingsFocus)?.scrollIntoView({ block: 'start' }), 0);
clearSettingsFocus();
}, [settingsFocus]); // eslint-disable-line react-hooks/exhaustive-deps
const [mfaSetup, setMfaSetup] = useState(null); // { secret, qrCodeDataUrl } while setup is in progress
@@ -177,7 +183,9 @@ export default function SettingsPage() {
const [reminderTest, setReminderTest] = useState({ busy: false, ok: false, message: '' });
const testReminder = async () => {
setReminderTest({ busy: true, ok: false, message: '' });
const result = await sendReminderTest({ ntfyTopic: reminderForm.ntfyTopic, ntfyUrl: reminderForm.ntfyUrl });
const server = normalizeNtfyServer(reminderForm.ntfyUrl, reminderForm.ntfyTopic);
if (server.error) { setReminderTest({ busy: false, ok: false, message: server.error }); return; }
const result = await sendReminderTest({ ntfyTopic: reminderForm.ntfyTopic, ntfyUrl: server.value });
setReminderTest(result.ok
? { busy: false, ok: true, message: 'Sent. Check your ntfy app or the topic page in a browser.' }
: { busy: false, ok: false, message: result.error ?? 'Could not send the test.' });
@@ -187,13 +195,16 @@ export default function SettingsPage() {
e.preventDefault();
setReminderError('');
setReminderBusy(true);
const result = await updateReminderSettings({ ...reminderForm, reminderTz: Intl.DateTimeFormat().resolvedOptions().timeZone });
const server = normalizeNtfyServer(reminderForm.ntfyUrl, reminderForm.ntfyTopic);
if (server.error) { setReminderBusy(false); setReminderError(server.error); return; }
const result = await updateReminderSettings({ ...reminderForm, ntfyUrl: server.value, reminderTz: Intl.DateTimeFormat().resolvedOptions().timeZone });
setReminderBusy(false);
if (!result.ok) {
setReminderError(result.error ?? 'Failed to save reminder settings.');
return;
}
setAuthUser((u) => ({ ...u, ...result.data }));
setReminderForm((f) => ({ ...f, ntfyUrl: server.value })); // shows the address as saved (with https:// added if it was missing)
setReminderSaved(true);
window.setTimeout(() => setReminderSaved(false), 2500);
};
@@ -225,13 +236,25 @@ export default function SettingsPage() {
return (
<PageShell title="Account Settings" onBack={goHome} maxWidth="max-w-2xl">
<main className="mx-auto max-w-2xl space-y-5 px-4 py-6">
<div role="tablist" aria-label="Settings sections" className="-mx-1 flex gap-1 overflow-x-auto px-1 pb-1">
{SETTINGS_TABS.map(([id, label]) => (
<button key={id} type="button" role="tab" id={`settings-tab-${id}`} aria-selected={tab === id} aria-controls="settings-panel" onClick={() => setTab(id)}
className={`shrink-0 rounded-full px-4 py-1.5 text-sm transition ${tab === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'border border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
{label}
</button>
))}
</div>
<div role="tabpanel" id="settings-panel" aria-labelledby={`settings-tab-${tab}`} className="space-y-5">
{tab === 'account' && (
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-2">
<h2 className="text-base font-medium text-[var(--r-text)]">Account</h2>
<p className="text-sm text-[var(--r-sub)]">
Signed in as <span className="font-medium text-[var(--r-text)]">{authUser.email}</span>
</p>
</section>
)}
{tab === 'prefs' && (
<section aria-label="Preferences" className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6">
<h2 className="text-base font-medium">Preferences</h2>
<p className="mb-4 mt-1 text-sm text-[var(--r-sub)]">How the app looks and reads. Study projects always use the Berean Standard Bible.</p>
@@ -275,7 +298,9 @@ export default function SettingsPage() {
</select>
</PrefRow>
</section>
)}
{tab === 'security' && (
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-4">
<div>
<h2 className="text-base font-medium text-[var(--r-text)]">Change password</h2>
@@ -327,7 +352,9 @@ export default function SettingsPage() {
</button>
</form>
</section>
)}
{tab === 'account' && (
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-4">
<div>
<h2 className="text-base font-medium text-[var(--r-text)]">Podcast / show name</h2>
@@ -355,9 +382,13 @@ export default function SettingsPage() {
</form>
{podcastNameSaved && <p className="text-sm text-[var(--r-accent)]">Saved.</p>}
</section>
)}
{tab === 'data' && (
<DataSection />
)}
{tab === 'prefs' && (
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-4">
<div>
<h2 className="text-base font-medium text-[var(--r-text)]">My reading journal</h2>
@@ -386,7 +417,9 @@ export default function SettingsPage() {
</div>
{journalError && <p className="text-sm text-[var(--r-danger,#b42318)]">{journalError}</p>}
</section>
)}
{tab === 'notify' && (
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-4">
<div>
<h2 className="text-base font-medium text-[var(--r-text)]">Reading reminders</h2>
@@ -394,7 +427,7 @@ export default function SettingsPage() {
Get a daily nudge via{' '}
<a href="https://ntfy.sh" target="_blank" rel="noreferrer" className="text-[var(--r-accent)] underline">ntfy.sh</a>
{' '}— a free push-notification service. Install the ntfy app (or open{' '}
<span className="font-mono text-xs">{(reminderForm.ntfyUrl || 'https://ntfy.sh').replace(/\/+$/, '')}/{reminderForm.ntfyTopic || '<topic>'}</span>
<span className="font-mono text-xs">{(normalizeNtfyServer(reminderForm.ntfyUrl, reminderForm.ntfyTopic).value || 'https://ntfy.sh')}/{reminderForm.ntfyTopic || '<topic>'}</span>
{' '}in a browser) and subscribe to the topic below to receive it.
</p>
</div>
@@ -465,6 +498,12 @@ export default function SettingsPage() {
onChange={(e) => setReminderForm((f) => ({ ...f, ntfyUrl: e.target.value }))}
className="mt-1 block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-[var(--r-text)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]"
/>
{normalizeNtfyServer(reminderForm.ntfyUrl, reminderForm.ntfyTopic).error && (
<span role="alert" className="mt-1 block text-xs text-[var(--r-danger,#b42318)]">
{normalizeNtfyServer(reminderForm.ntfyUrl, reminderForm.ntfyTopic).error}{' '}
<button type="button" onClick={() => setReminderForm((f) => ({ ...f, ntfyUrl: '' }))} className="underline">Clear it</button>
</span>
)}
</label>
{reminderError && <p className="text-sm text-[var(--r-danger,#b42318)]">{reminderError}</p>}
{reminderSaved && <p className="text-sm text-[var(--r-accent)]">Saved.</p>}
@@ -490,7 +529,9 @@ export default function SettingsPage() {
</div>
</form>
</section>
)}
{tab === 'security' && (
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-4">
<div>
<h2 className="text-base font-medium text-[var(--r-text)]">Two-factor authentication</h2>
@@ -592,6 +633,10 @@ export default function SettingsPage() {
</div>
)}
</section>
)}
{tab === 'security' && <PasskeysSection />}
{tab === 'security' && <DevicesSection />}
</div>
</main>
</PageShell>
);