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,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import AdminSignup from '../components/AdminSignup.jsx';
|
||||
import AdminHealth from '../components/AdminHealth.jsx';
|
||||
import PageShell from '../components/study/PageShell.jsx';
|
||||
import { buildExportHtml } from '../utils/bibleUtils.jsx';
|
||||
import {
|
||||
@@ -14,7 +15,7 @@ import {
|
||||
|
||||
export default function AdminPage() {
|
||||
const { authUser, goHome } = useApp();
|
||||
const [adminTab, setAdminTab] = useState('users'); // 'users' | 'projects' | 'signup'
|
||||
const [adminTab, setAdminTab] = useState('users'); // 'users' | 'projects' | 'signup' | 'health'
|
||||
const [adminUsers, setAdminUsers] = useState([]);
|
||||
const [adminProjects, setAdminProjects] = useState([]);
|
||||
const [adminLoading, setAdminLoading] = useState(false);
|
||||
@@ -84,9 +85,14 @@ export default function AdminPage() {
|
||||
className={`rounded-full px-4 py-1.5 text-sm transition ${adminTab === 'signup' ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'border border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
|
||||
Sign-ups
|
||||
</button>
|
||||
<button type="button" onClick={() => setAdminTab('health')}
|
||||
className={`rounded-full px-4 py-1.5 text-sm transition ${adminTab === 'health' ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'border border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
|
||||
Health
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{adminTab === 'signup' && <AdminSignup />}
|
||||
{adminTab === 'health' && <AdminHealth />}
|
||||
|
||||
{adminLoading && <p className="text-sm text-[var(--r-sub)]">Loading…</p>}
|
||||
{adminError && <p className="text-sm text-[var(--r-danger,#b42318)]">{adminError}</p>}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { themeVars, READER_THEMES } from '../components/reader/readerThemes.js';
|
||||
import { passkeysSupported, signInWithPasskey } from '../utils/passkeys.js';
|
||||
import ResetPasswordForm from '../components/ResetPasswordForm.jsx';
|
||||
import { getSignupMode, loginUser, registerUser, verifyMfaLogin, logoutUser } from '../syncService.js';
|
||||
|
||||
@@ -124,6 +125,15 @@ export default function AuthPage() {
|
||||
signIn(result.data);
|
||||
};
|
||||
|
||||
const submitPasskey = async () => {
|
||||
setAuthError('');
|
||||
setAuthBusy(true);
|
||||
const result = await signInWithPasskey();
|
||||
setAuthBusy(false);
|
||||
if (result.ok) signIn(result.data);
|
||||
else if (!result.cancelled) setAuthError(result.error ?? 'Passkey sign-in failed.');
|
||||
};
|
||||
|
||||
const submitMfa = async (e) => {
|
||||
e.preventDefault();
|
||||
setAuthError('');
|
||||
@@ -265,6 +275,10 @@ export default function AuthPage() {
|
||||
{authBusy ? 'Please wait…' : authMode === 'login' ? 'Sign in' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
{authMode === 'login' && passkeysSupported() && (
|
||||
<button type="button" onClick={submitPasskey} disabled={authBusy}
|
||||
className="mt-3 w-full rounded-full border border-[var(--r-line)] px-4 py-2.5 text-sm text-[var(--r-text)] hover:bg-black/5 disabled:opacity-50">Sign in with a passkey</button>
|
||||
)}
|
||||
{authMode === 'login' && (
|
||||
<button type="button" onClick={() => { setAuthMode('forgot'); setAuthError(''); }}
|
||||
className="mt-4 w-full text-center text-sm text-[var(--r-sub)] underline hover:text-[var(--r-text)]">Forgot your password?</button>
|
||||
|
||||
@@ -21,6 +21,8 @@ import NavigatorSheet from '../components/reader/NavigatorSheet.jsx';
|
||||
import SettingsSheet from '../components/reader/SettingsSheet.jsx';
|
||||
import CompareSheet from '../components/reader/CompareSheet.jsx';
|
||||
import SearchSheet from '../components/reader/SearchSheet.jsx';
|
||||
import CrossRefMap from '../components/reader/CrossRefMap.jsx';
|
||||
import { useIqAvailable } from '../hooks/useIq.js';
|
||||
import MarksSheet from '../components/reader/MarksSheet.jsx';
|
||||
import { useToast } from '../components/Toasts.jsx';
|
||||
import { HIGHLIGHT_COLORS, colorTitle } from '../utils/highlightColors.js';
|
||||
@@ -107,7 +109,8 @@ export default function ReaderPage() {
|
||||
const parallelChapter = useParallelChapter(parallelId, readerBookAbbrev, readerChapter);
|
||||
const parallelSource = READER_TRANSLATIONS.find((t) => t.id === parallelId);
|
||||
|
||||
const [sheet, setSheet] = useState(null); // null | 'nav' | 'settings' | 'search' | 'bookmarks' | 'highlights' | 'audio'
|
||||
const [sheet, setSheet] = useState(null); // null | 'nav' | 'settings' | 'search' | 'bookmarks' | 'highlights' | 'audio' | 'xmap'
|
||||
const iqOn = useIqAvailable();
|
||||
const [chromeVisible, setChromeVisible] = useState(true);
|
||||
const [readerDrawMode, setReaderDrawMode] = useState(false);
|
||||
const [selectionPicker, setSelectionPicker] = useState(null);
|
||||
@@ -284,6 +287,7 @@ export default function ReaderPage() {
|
||||
else if (e.key === 'g' || e.key === 'G') { e.preventDefault(); openSheet('nav'); }
|
||||
else if (e.key === ',') { e.preventDefault(); openSheet('settings'); }
|
||||
else if (e.key === 'c' || e.key === 'C') { e.preventDefault(); openSheet('compare'); }
|
||||
else if ((e.key === 'm' || e.key === 'M') && iqOn) { e.preventDefault(); openSheet('xmap'); }
|
||||
else if (e.key === 'h' || e.key === 'H') { e.preventDefault(); openSheet('highlights'); }
|
||||
else if (e.key === 'f' || e.key === 'F') setChromeVisible((v) => !v);
|
||||
else if ((e.key === 'b' || e.key === 'B') && selectedKey) {
|
||||
@@ -575,7 +579,7 @@ export default function ReaderPage() {
|
||||
crossRefsLoading={readerCrossRefsLoading}
|
||||
onLoadCrossRefs={() => { if (!readerCrossRefs) loadReaderCrossRefs(readerBookAbbrev, readerChapter); }}
|
||||
bookmarkColor={readerBookmarks[selectedKey]}
|
||||
studiesHere={studiesHere} onOpenStudy={openStudyChunk} onPray={startPrayerFor}
|
||||
studiesHere={studiesHere} onOpenStudy={openStudyChunk} onPray={startPrayerFor} onOpenMap={iqOn ? () => openSheet('xmap') : undefined}
|
||||
bookmarkNote={readerBookmarkNotes?.[selectedKey] ?? ''} onSetBookmarkNote={(text) => setBookmarkNote(selectedKey, text)} onSetHighlightNote={setHighlightNote}
|
||||
onToggleBookmark={() => (readerBookmarks[selectedKey] ? removeBookmark(selectedKey, readerBookmarks[selectedKey]) : toggleReaderBookmark(selectedKey))}
|
||||
onCycleColor={() => cycleBookmarkColor(selectedKey)}
|
||||
@@ -641,6 +645,7 @@ export default function ReaderPage() {
|
||||
bibleMatches={bibleMatches} deferredBibleQuery={deferredBibleQuery} remote={remoteSearch}
|
||||
onJump={jump}
|
||||
/>
|
||||
{iqOn && <CrossRefMap open={sheet === 'xmap'} onClose={closeSheet} book={readerBookAbbrev} chapter={readerChapter} onOpenChapter={(b, c) => jump(b, c, 1)} />}
|
||||
<MarksSheet
|
||||
open={sheet === 'bookmarks' || sheet === 'highlights'} onClose={closeSheet}
|
||||
initialTab={sheet === 'bookmarks' ? 'bookmarks' : 'highlights'}
|
||||
|
||||
@@ -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