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
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user