Preferences in Settings and a home page the user chooses
Build and Push Docker Image / build-and-push (push) Failing after 20s

Settings gets a Preferences section: theme, default Bible, the Bible new reading
plans start in, verse layout, commentary, text size, reading width, audio
narrator (the last three stay per device; text size and narrator are now
remembered). A Home page setting (My Studies, Bible Reader or Reading Plans)
decides where the app opens and where every Home button goes; already being
there means My Studies. Back arrows are labelled Home. Tests cover every page
leading back.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-09-29 10:55:07 -04:00
1 parent 760c1e568b
commit 8c4f54e033
15 files changed
+401 -23

No files matched your search

+2
View File
@@ -5,6 +5,8 @@ const ENUMS = {
theme: ['paper', 'sepia', 'dark'],
translation: ['BSB', 'ESV', 'NLT', 'CSB', 'NASB', 'NET'],
layout: ['flow', 'verses'],
startPage: ['studies', 'reader', 'plans'], // where the app opens and where Home buttons go
planTranslation: ['same', 'BSB', 'ESV', 'NLT', 'CSB', 'NASB', 'NET'], // which Bible new reading plans start in
commentarySource: ['matthew-henry', 'john-gill', 'jamieson-fausset-brown', 'tyndale', 'adam-clarke', 'john-calvin', 'keil-delitzsch'],
};
const DAY_MS = 86_400_000;
+5
View File
@@ -14,6 +14,11 @@ test('only known preferences with valid values are accepted', () => {
expect(cleanPrefValue('translation', 'KJV')).toBeUndefined();
expect(cleanPrefValue('layout', 'flow')).toBe('flow');
expect(cleanPrefValue('commentarySource', 'tyndale')).toBe('tyndale');
expect(cleanPrefValue('startPage', 'reader')).toBe('reader');
expect(cleanPrefValue('startPage', 'admin')).toBeUndefined();
expect(cleanPrefValue('planTranslation', 'same')).toBe('same');
expect(cleanPrefValue('planTranslation', 'NASB')).toBe('NASB');
expect(cleanPrefValue('planTranslation', 'KJV')).toBeUndefined();
expect(cleanPrefValue('fontSize', 2)).toBeUndefined(); // not a synced preference
});
+26 -3
View File
@@ -71,7 +71,7 @@ const App = () => {
reader.stopMarksSync();
reader.stopPrefsSync();
setProjectIndex(switchStorageUser(null));
goHome();
goStudies();
},
});
const library = useProjectLibrary({
@@ -174,7 +174,7 @@ const App = () => {
// player) so this works even without opening the reader first.
const listenReadingPlanDay = (bookAbbrev, chapter) => {
audio.handlePlayBookAudioFrom(bookAbbrev, chapter);
goHome();
goStudies();
};
// Builds a new chunk-builder project seeded with a reading-plan day's
@@ -228,12 +228,34 @@ const App = () => {
setCurrentPage('import');
};
const goHome = () => {
// The My Studies page.
const goStudies = () => {
setProjectIndex(loadProjectIndex());
setCurrentPage('home');
setProject(null);
};
// "Home": the page the user chose in Settings (My Studies, the Bible Reader, or Reading
// Plans). If they're already on it, Home means My Studies, so there is always somewhere to go.
const START_PAGES = { studies: 'home', reader: 'reader', plans: 'reading-plans' };
const goHome = () => {
const target = START_PAGES[reader.startPagePref] ?? 'home';
if (target === 'home' || target === currentPage) { goStudies(); return; }
setProjectIndex(loadProjectIndex());
setProject(null);
setCurrentPage(target);
};
// Open the app on the chosen home page (once, as soon as we know who is signed in).
const openedStartPageRef = useRef(false);
useEffect(() => {
if (authUser === undefined || openedStartPageRef.current) return;
openedStartPageRef.current = true;
const target = START_PAGES[reader.startPagePref] ?? 'home';
if (target !== 'home') setCurrentPage(target);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [authUser]);
// Manual retry after a failed background sync (shown in the project header).
const retrySync = async () => {
if (!project) return;
@@ -251,6 +273,7 @@ const App = () => {
// Navigation
currentPage, setCurrentPage,
goHome,
goStudies,
authServerDown,
// Auth (login/2FA/settings form state lives in AuthPage and SettingsPage)
authUser, setAuthUser,
+215 -2
View File
@@ -509,6 +509,17 @@ describe('Admin page', () => {
expect(fetchMock.mock.calls.some(([url, o = {}]) => url === '/api/admin/users/u2' && o.method === 'DELETE')).toBe(true);
});
test('the Admin page leads back to the dashboard', async () => {
stubAdminApi(admin);
const u = userEvent.setup();
render(<App />);
await openAccountMenu(u);
await u.click(await screen.findByRole('menuitem', { name: /admin/i }));
await screen.findByText('student@example.com');
await u.click(screen.getByRole('button', { name: /^back$/i }));
expect(await screen.findByText('My Studies')).toBeInTheDocument();
});
test('non-admins get no Admin button', async () => {
stubAdminApi({ ...admin, isAdmin: false });
render(<App />);
@@ -593,7 +604,7 @@ describe('Home page', () => {
await renderApp();
await user.selectOptions(screen.getByDisplayValue('Last edited'), 'title');
await user.click(screen.getByRole('button', { name: /read bible/i }));
await user.click(await screen.findByRole('button', { name: /back to studies/i }));
await user.click(await screen.findByRole('button', { name: /back to home/i }));
expect(await screen.findByDisplayValue('Title')).toBeInTheDocument();
});
@@ -619,7 +630,7 @@ describe('Home page', () => {
expect(screen.getByText('ESV')).toBeInTheDocument(); // plan strip shows the plan's translation
expect(fetchMock.mock.calls.some(([u]) => String(u).startsWith('/api/bible/ESV/MAT/1'))).toBe(true);
// Leaving the reader ends the plan context: the reader's own translation applies again
await user.click(screen.getByRole('button', { name: /back to studies/i }));
await user.click(screen.getByRole('button', { name: /back to home/i }));
await user.click(screen.getByRole('button', { name: /read bible/i }));
expect(await screen.findByText(/Matthew 1 · BSB/)).toBeInTheDocument();
});
@@ -1128,6 +1139,77 @@ describe('Preference sync', () => {
await waitFor(() => expect(localStorage.getItem('reader-commentary')).toBe('tyndale'));
});
async function openSettings(u) {
await u.click(await screen.findByRole('button', { name: /account menu/i }));
await u.click(await screen.findByRole('menuitem', { name: /settings/i }));
return screen.findByRole('region', { name: 'Preferences' });
}
test('Settings shows your preferences, and choosing a default Bible saves it and syncs it', async () => {
const server = stubPrefsServer({ translation: { v: 'BSB', at: 10 } });
const u = userEvent.setup();
await renderApp();
await waitFor(() => expect(server.puts.length).toBeGreaterThan(0));
server.puts.length = 0;
const prefs = within(await openSettings(u));
expect(prefs.getByLabelText('Default Bible')).toHaveValue('BSB');
await u.selectOptions(prefs.getByLabelText('Default Bible'), 'ESV');
expect(localStorage.getItem('reader-translation')).toBe('ESV');
await waitFor(() => expect(server.puts).toHaveLength(1), { timeout: 4000 });
expect(server.puts[0].translation.v).toBe('ESV');
});
test('theme, verse layout, commentary and new-plan Bible can all be set from Settings', async () => {
stubPrefsServer();
const u = userEvent.setup();
await renderApp();
const prefs = within(await openSettings(u));
await u.click(within(prefs.getByRole('group', { name: 'Theme' })).getByRole('button', { name: 'Dark' }));
await u.click(within(prefs.getByRole('group', { name: 'Verse layout' })).getByRole('button', { name: 'One per line' }));
await u.selectOptions(prefs.getByLabelText('Commentary'), 'tyndale');
await u.selectOptions(prefs.getByLabelText('New reading plans'), 'NLT');
expect(localStorage.getItem('reader-theme')).toBe('dark');
expect(localStorage.getItem('reader-layout')).toBe('verses');
expect(localStorage.getItem('reader-commentary')).toBe('tyndale');
expect(localStorage.getItem('plan-translation')).toBe('NLT');
});
test('text size, reading width and narrator are remembered on this device (not synced)', async () => {
const server = stubPrefsServer();
const u = userEvent.setup();
await renderApp();
await waitFor(() => expect(server.puts.length).toBeGreaterThan(0));
server.puts.length = 0;
const prefs = within(await openSettings(u));
await u.click(within(prefs.getByRole('group', { name: 'Text size' })).getByRole('button', { name: 'XL' }));
await u.click(within(prefs.getByRole('group', { name: 'Reading width' })).getByRole('button', { name: 'Two columns' }));
await u.selectOptions(prefs.getByLabelText('Audio narrator'), 'hays');
expect(localStorage.getItem('reader-font-size')).toBe('1.25');
expect(localStorage.getItem('reader-wide')).toBe('1');
expect(localStorage.getItem('audio-narrator')).toBe('hays');
await new Promise((r) => setTimeout(r, 1500));
expect(server.puts).toHaveLength(0); // per-device settings stay off the account
});
test('Settings leads back to the dashboard', async () => {
stubPrefsServer();
const u = userEvent.setup();
await renderApp();
await openSettings(u);
await u.click(screen.getByRole('button', { name: /bible study home/i }));
expect(await screen.findByText('My Studies')).toBeInTheDocument();
});
test('a new reading plan starts in the Bible chosen for new plans', async () => {
localStorage.setItem('plan-translation', 'CSB');
stubPrefsServer({ planTranslation: { v: 'CSB', at: 10 } });
const u = userEvent.setup();
await renderApp();
await u.click(screen.getByRole('button', { name: /reading plans/i }));
await u.click(await screen.findByRole('button', { name: /The Gospels in 30 Days/ }));
expect(screen.getByRole('combobox')).toHaveValue('CSB');
});
test('signing out forgets the sync timestamps', async () => {
stubPrefsServer({ theme: { v: 'dark', at: 900 } });
const u = userEvent.setup();
@@ -1138,3 +1220,134 @@ describe('Preference sync', () => {
await waitFor(() => expect(localStorage.getItem('prefs-meta')).toBeNull());
});
});
// ---------------------------------------------------------------------------
// Every page has a way back to the dashboard
// ---------------------------------------------------------------------------
describe('Back to the dashboard', () => {
const home = /back to home|bible study home/i;
const dashboard = async () => expect(await screen.findByText('My Studies')).toBeInTheDocument();
test.each([
['the Reader', async (u) => { await u.click(screen.getByRole('button', { name: /read bible/i })); await screen.findByText(/Genesis 1 · BSB/); }],
['Reading Plans', async (u) => { await u.click(screen.getByRole('button', { name: /reading plans/i })); await screen.findByText(/browse plans/i); }],
['Import', async (u) => { await u.click(screen.getByRole('button', { name: /import session list/i })); await screen.findByRole('heading', { name: /import session list/i }); }],
])('%s', async (_name, open) => {
const u = userEvent.setup();
await renderApp();
await open(u);
await u.click(screen.getByRole('button', { name: home }));
await dashboard();
});
test.each(['Outline', 'Study', 'Script', 'Export'])('the %s stage of a study', async (stage) => {
const u = await loadChapter();
addChunk('Paul, a servant', 'In hope of eternal');
await screen.findByText('1 created');
await u.click(screen.getByRole('button', { name: new RegExp(`^${stage}$`, 'i') }));
await screen.findByRole('navigation', { name: /study stages/i });
await u.click(screen.getByRole('button', { name: home }));
await dashboard();
});
test('the Pronunciation guide', async () => {
const u = await loadChapter();
addChunk('Paul, a servant', 'Paul, a servant');
await screen.findByText('1 created');
await u.click(screen.getByRole('button', { name: /^script$/i }));
await u.click(await screen.findByRole('button', { name: /pronunciation guide/i }));
await screen.findByRole('heading', { name: /pronunciation guide/i });
await u.click(screen.getByRole('button', { name: home }));
await dashboard();
});
});
// ---------------------------------------------------------------------------
// The home page the user chooses
// ---------------------------------------------------------------------------
describe('Chosen home page', () => {
const homeBtn = /back to home|bible study home/i;
test('the app opens on the chosen page', async () => {
localStorage.setItem('start-page', 'reader');
render(<App />);
expect(await screen.findByText(/Genesis 1 · BSB/)).toBeInTheDocument();
expect(screen.queryByText('My Studies')).not.toBeInTheDocument();
});
test('by default the app opens on My Studies', async () => {
await renderApp();
expect(screen.getByText('My Studies')).toBeInTheDocument();
});
test('Home buttons go to the chosen page, and to My Studies when you are already on it', async () => {
localStorage.setItem('start-page', 'reader');
const u = userEvent.setup();
render(<App />);
await screen.findByText(/Genesis 1 · BSB/);
// already on the chosen page: Home means My Studies
await u.click(screen.getByRole('button', { name: homeBtn }));
expect(await screen.findByText('My Studies')).toBeInTheDocument();
// from another page, Home is the chosen page
await u.click(screen.getByRole('button', { name: /reading plans/i }));
await screen.findByText(/browse plans/i);
await u.click(screen.getByRole('button', { name: homeBtn }));
expect(await screen.findByText(/Genesis 1 · BSB/)).toBeInTheDocument();
});
test('the logo on a page with an app bar goes to the chosen page, and Studies always goes to My Studies', async () => {
localStorage.setItem('start-page', 'plans');
const u = userEvent.setup();
render(<App />);
await screen.findByText(/browse plans/i); // opened on Reading Plans
await u.click(screen.getByRole('button', { name: homeBtn })); // already there -> My Studies
await screen.findByText('My Studies');
await u.click(screen.getByRole('button', { name: /import session list/i }));
await screen.findByRole('heading', { name: /import session list/i });
await u.click(screen.getByRole('button', { name: /bible study home/i }));
expect(await screen.findByText(/browse plans/i)).toBeInTheDocument();
await u.click(screen.getByRole('button', { name: homeBtn })); // -> My Studies again
await u.click(screen.getByRole('button', { name: /import session list/i }));
await u.click(screen.getByRole('button', { name: /^studies$/i }));
expect(await screen.findByText('My Studies')).toBeInTheDocument();
});
test('leaving a study with a different home page closes the study', async () => {
localStorage.setItem('start-page', 'plans');
const u = userEvent.setup();
render(<App />);
await screen.findByText(/browse plans/i);
await u.click(screen.getByRole('button', { name: homeBtn })); // to My Studies
await u.click(screen.getAllByRole('button', { name: /new project/i })[0]);
await u.click(await screen.findByRole('button', { name: /load chapter/i }));
await screen.findByText(/Scripture & Chunks/i);
await u.click(screen.getByRole('button', { name: homeBtn }));
expect(await screen.findByText(/browse plans/i)).toBeInTheDocument();
});
test('Settings changes it, and it is saved for next time', async () => {
const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: null, isAdmin: false };
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
const fallback = buildFetchMock();
vi.stubGlobal('fetch', vi.fn((url, opts = {}) => {
if (!url.startsWith('/api/')) return fallback(url, opts);
const route = `${opts.method ?? 'GET'} ${url}`;
if (route === 'GET /api/auth/me') return json(200, user);
if (route === 'GET /api/projects') return json(200, []);
if (route === 'GET /api/reader/ink') return json(200, {});
if (route === 'GET /api/reader/marks') return json(200, { bookmarks: {}, highlights: [], updatedAt: 0 });
if (route === 'GET /api/prefs') return json(200, { prefs: {} });
if (route === 'PUT /api/prefs') return json(200, { prefs: {} });
return json(404, { error: 'not mocked' });
}));
const u = userEvent.setup();
await renderApp();
await u.click(await screen.findByRole('button', { name: /account menu/i }));
await u.click(await screen.findByRole('menuitem', { name: /settings/i }));
const prefs = within(await screen.findByRole('region', { name: 'Preferences' }));
await u.click(within(prefs.getByRole('group', { name: 'Home page' })).getByRole('button', { name: 'Reading Plans' }));
expect(localStorage.getItem('start-page')).toBe('plans');
await u.click(screen.getByRole('button', { name: homeBtn })); // Settings back -> the new home page
expect(await screen.findByText(/browse plans/i)).toBeInTheDocument();
});
});
+1 -1
View File
@@ -9,7 +9,7 @@ export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks,
return (
<div className="sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)] font-sans">
<div className="mx-auto flex h-12 max-w-4xl items-center gap-1 px-2">
<button type="button" onClick={onBack} className={iconBtn} aria-label="Back to studies"><ChevronLeft /></button>
<button type="button" onClick={onBack} className="flex h-9 items-center gap-0.5 rounded-lg pl-1 pr-2 text-[var(--r-sub)] transition hover:bg-black/5" aria-label="Back to home"><ChevronLeft /><span className="hidden text-xs sm:inline">Home</span></button>
<button type="button" onClick={onOpenNavigator}
className="mx-auto flex min-w-0 items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium text-[var(--r-text)] hover:bg-black/5">
<span className="truncate">{title}</span><ChevronDown />
+2 -2
View File
@@ -61,8 +61,8 @@ function AccountMenu() {
}
export default function PageShell({ title, onBack, actions, active = null, maxWidth = 'max-w-6xl', children }) {
const { readerTheme, goHome, openBibleReader, openReadingPlans } = useApp();
const go = { studies: goHome, reader: openBibleReader, plans: openReadingPlans };
const { readerTheme, goHome, goStudies, openBibleReader, openReadingPlans } = useApp();
const go = { studies: goStudies, reader: openBibleReader, plans: openReadingPlans };
return (
<div className="min-h-screen bg-[var(--r-bg)] font-sans text-[var(--r-text)]" style={themeVars(readerTheme)}>
<header className="sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)]">
+2 -2
View File
@@ -26,8 +26,8 @@ export default function StudioShell({ stage, loading = false, fixedHeight = fals
<div className={`bg-[var(--r-bg)] font-sans text-[var(--r-text)] ${fixedHeight ? 'flex h-screen flex-col overflow-hidden' : 'min-h-screen'}`} style={themeVars(readerTheme)}>
<div className="z-30 shrink-0 border-b border-[var(--r-line)] bg-[var(--r-bar)]">
<div className="mx-auto flex h-12 max-w-[1600px] items-center gap-3 px-3">
<button type="button" onClick={goHome} aria-label="Back to studies"
className="flex h-9 w-9 items-center justify-center rounded-lg text-[var(--r-sub)] hover:bg-black/5"><ChevronLeft /></button>
<button type="button" onClick={goHome} aria-label="Back to home"
className="flex h-9 items-center gap-0.5 rounded-lg pl-1 pr-2 text-[var(--r-sub)] hover:bg-black/5"><ChevronLeft /><span className="hidden text-xs sm:inline">Home</span></button>
<div className="min-w-0 max-w-[16rem] truncate text-sm font-medium">{project?.title ?? ''}</div>
<nav aria-label="Study stages" className="mx-auto flex gap-0.5">
{STAGES.map((s) => {
+10 -1
View File
@@ -5,7 +5,16 @@ import { bookOptions, CHAPTER_COUNTS } from '../utils/bibleUtils.jsx';
export function useAudio(readerBookAbbrev, readerChapter, readerTranslation = 'BSB') {
const [audioBook, setAudioBook] = useState(bookOptions[0].abbrev);
const [audioNarrator, setAudioNarrator] = useState('souer');
const [audioNarrator, setAudioNarratorState] = useState(() => {
try {
const saved = localStorage.getItem('audio-narrator');
return ['david', 'hays', 'souer'].includes(saved) ? saved : 'souer';
} catch { return 'souer'; }
});
const setAudioNarrator = (id) => {
setAudioNarratorState(id);
try { localStorage.setItem('audio-narrator', id); } catch { /* storage unavailable */ }
};
const [audioState, setAudioState] = useState({ status: 'idle', chapter: 0, total: 0 });
const [readerAudioState, setReaderAudioState] = useState({ status: 'idle', chapter: 0 });
const [audioProgress, setAudioProgress] = useState({ currentTime: 0, duration: 0 });
+46 -1
View File
@@ -73,7 +73,46 @@ export function useBibleReader(currentPage, translationOverride = null) {
const [readerInterlinearLoading, setReaderInterlinearLoading] = useState(false);
const [readerSelectedVerse, setReaderSelectedVerse] = useState(null); // verse number with open interlinear panel
const _readerInterlinearCacheRef = useRef({});
const [readerFontSize, setReaderFontSize] = useState(1); // em multiplier: 0.875 | 1 | 1.125 | 1.25
// Text size and reading width are per-device (a phone and a tablet want different ones)
const [readerFontSize, setReaderFontSizeState] = useState(() => {
try {
const n = Number(localStorage.getItem('reader-font-size'));
return [0.875, 1, 1.125, 1.25, 1.5].includes(n) ? n : 1;
} catch { return 1; }
}); // em multiplier: 0.875 | 1 | 1.125 | 1.25 | 1.5
const setReaderFontSize = (n) => {
setReaderFontSizeState(n);
try { localStorage.setItem('reader-font-size', String(n)); } catch {}
};
const [readerWide, setReaderWideState] = useState(() => {
try { return localStorage.getItem('reader-wide') === '1'; } catch { return false; }
});
const setReaderWide = (on) => {
setReaderWideState(!!on);
try { localStorage.setItem('reader-wide', on ? '1' : '0'); } catch {}
};
// The page the app opens to, and where every Home button goes: 'studies' | 'reader' | 'plans'
const [startPagePref, setStartPagePrefState] = useState(() => {
try {
const saved = localStorage.getItem('start-page');
return ['studies', 'reader', 'plans'].includes(saved) ? saved : 'studies';
} catch { return 'studies'; }
});
const setStartPagePref = (id) => {
setStartPagePrefState(id);
try { localStorage.setItem('start-page', id); } catch {}
};
// Which Bible a newly started reading plan begins in: 'same' = whatever the default Bible is
const [planTranslationPref, setPlanTranslationPrefState] = useState(() => {
try {
const saved = localStorage.getItem('plan-translation');
return saved === 'same' || READER_TRANSLATIONS.some((t) => t.id === saved) ? saved : 'same';
} catch { return 'same'; }
});
const setPlanTranslationPref = (id) => {
setPlanTranslationPrefState(id);
try { localStorage.setItem('plan-translation', id); } catch {}
};
const [readerBookmarks, setReaderBookmarks] = useState(() => {
try { return JSON.parse(localStorage.getItem('reader-bookmarks') || '{}'); } catch { return {}; }
});
@@ -397,12 +436,15 @@ export function useBibleReader(currentPage, translationOverride = null) {
const prefsSync = usePrefsSync({
values: {
theme: readerTheme, translation: baseTranslation, layout: readerLayout, commentarySource: readerCommentary,
planTranslation: planTranslationPref, startPage: startPagePref,
position: { book: readerBookAbbrev, chapter: readerChapter },
},
apply: (key, v) => {
if (key === 'theme') { setReaderThemeState(v); try { localStorage.setItem('reader-theme', v); } catch {} }
else if (key === 'translation') { setReaderTranslationState(v); try { localStorage.setItem('reader-translation', v); } catch {} }
else if (key === 'layout') { setReaderLayoutState(v); try { localStorage.setItem('reader-layout', v); } catch {} }
else if (key === 'startPage') { setStartPagePrefState(v); try { localStorage.setItem('start-page', v); } catch {} }
else if (key === 'planTranslation') { setPlanTranslationPrefState(v); try { localStorage.setItem('plan-translation', v); } catch {} }
else if (key === 'commentarySource') { setReaderCommentaryState(v); try { localStorage.setItem('reader-commentary', v); } catch {} }
else if (key === 'position') { setReaderBookAbbrev(v.book); setReaderChapter(v.chapter); setReaderSelectedVerse(null); }
},
@@ -424,6 +466,9 @@ export function useBibleReader(currentPage, translationOverride = null) {
readerInterlinear,
readerSelectedVerse, setReaderSelectedVerse,
readerFontSize, setReaderFontSize,
readerWide, setReaderWide,
planTranslationPref, setPlanTranslationPref,
startPagePref, setStartPagePref,
readerBookmarks, setReaderBookmarks,
readerBookmarksPanelOpen, setReaderBookmarksPanelOpen,
readerCrossRefs, setReaderCrossRefs,
+3 -3
View File
@@ -5,7 +5,7 @@
import { useCallback, useEffect, useRef } from 'react';
import { getRemotePrefs, saveRemotePrefs } from '../syncService.js';
export const PREF_KEYS = ['theme', 'translation', 'layout', 'commentarySource', 'position'];
export const PREF_KEYS = ['theme', 'translation', 'layout', 'commentarySource', 'planTranslation', 'startPage', 'position'];
const META_KEY = 'prefs-meta'; // { key: timestamp of the last change made on this device }
const PUSH_DELAY_MS = 1000;
const FOCUS_PULL_MIN_MS = 30_000;
@@ -16,7 +16,7 @@ const lsDel = (k) => { try { localStorage.removeItem(k); } catch { /* storage un
const readMeta = () => { try { return JSON.parse(lsGet(META_KEY) || '{}'); } catch { return {}; } };
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
// values: { theme, translation, layout, commentarySource, position: { book, chapter } }
// values: { theme, translation, layout, commentarySource, planTranslation, startPage, position: { book, chapter } }
// apply(key, value): puts a server value on screen and in localStorage
// canApplyPosition(): false while the Reader is open, so a background sync never moves the page under you
export function usePrefsSync({ values, apply, canApplyPosition = () => true }) {
@@ -58,7 +58,7 @@ export function usePrefsSync({ values, apply, canApplyPosition = () => true }) {
window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(push, PUSH_DELAY_MS);
}
}, [values.theme, values.translation, values.layout, values.commentarySource, values.position.book, values.position.chapter, push]); // eslint-disable-line react-hooks/exhaustive-deps
}, [values.theme, values.translation, values.layout, values.commentarySource, values.planTranslation, values.startPage, values.position.book, values.position.chapter, push]); // eslint-disable-line react-hooks/exhaustive-deps
const pullServerPrefs = useCallback(async () => {
enabledRef.current = true;
+2 -2
View File
@@ -19,7 +19,7 @@ function setup({ canApplyPosition = () => true, initial = {} } = {}) {
const [layout, setLayout] = useState(initial.layout ?? 'flow');
const [position, setPosition] = useState(initial.position ?? { book: 'GEN', chapter: 1 });
const sync = usePrefsSync({
values: { theme, translation: 'BSB', layout, commentarySource: 'matthew-henry', position },
values: { theme, translation: 'BSB', layout, commentarySource: 'matthew-henry', planTranslation: 'same', startPage: 'studies', position },
apply: (key, v) => { if (key === 'theme') setTheme(v); else if (key === 'layout') setLayout(v); else if (key === 'position') setPosition(v); },
canApplyPosition,
});
@@ -45,7 +45,7 @@ test('a device with no history takes the account\'s settings; a first device upl
await act(async () => { await h.result.current.pullServerPrefs(); });
expect(h.result.current.theme).toBe('sepia');
// keys the account doesn't have yet are filled in from this device
expect(Object.keys(remote.puts[0]).sort()).toEqual(['commentarySource', 'layout', 'position', 'translation']);
expect(Object.keys(remote.puts[0]).sort()).toEqual(['commentarySource', 'layout', 'planTranslation', 'position', 'startPage', 'translation']);
});
test('reading position is not moved under you while the Reader is open', async () => {
+1 -2
View File
@@ -39,6 +39,7 @@ export default function ReaderPage() {
readerInterlinear,
readerSelectedVerse, setReaderSelectedVerse,
readerFontSize, setReaderFontSize,
readerWide: readerWideLayout, setReaderWide: setReaderWideLayout,
readerBookmarks,
readerCrossRefs, setReaderCrossRefs,
readerCrossRefsLoading,
@@ -84,12 +85,10 @@ export default function ReaderPage() {
const [sheet, setSheet] = useState(null); // null | 'nav' | 'settings' | 'search' | 'bookmarks' | 'audio'
const [chromeVisible, setChromeVisible] = useState(true);
const [readerDrawMode, setReaderDrawMode] = useState(false);
const [readerWideLayout, setReaderWideLayout] = useState(() => localStorage.getItem('reader-wide') === '1');
const [selectionPicker, setSelectionPicker] = useState(null);
const swipeTouchRef = useRef(null);
const readerPageInkStrokes = readerInkByPage?.[`${readerBookAbbrev}_${readerChapter}`] ?? NO_STROKES;
useEffect(() => { localStorage.setItem('reader-wide', readerWideLayout ? '1' : '0'); }, [readerWideLayout]);
const chapterKey = `${readerBookAbbrev}.${readerChapter}`;
const label = (ref) => formatDayReadings([ref]);
+1 -1
View File
@@ -12,7 +12,7 @@ const ctx = {
readerVerses: [{ number: 1, text: 'Now there was a man of the Pharisees.' }, { number: 2, text: 'This man came to Jesus by night.' }],
readerTotalChapters: 21, readerLoading: false, readerError: '',
readerInterlinear: null, readerSelectedVerse: null, setReaderSelectedVerse: vi.fn(),
readerFontSize: 1, setReaderFontSize: noop, readerBookmarks: {},
readerFontSize: 1, setReaderFontSize: noop, readerWide: false, setReaderWide: noop, readerBookmarks: {},
readerCrossRefs: null, setReaderCrossRefs: noop, readerCrossRefsLoading: false,
readerShowCrossRefs: false, setReaderShowCrossRefs: noop,
readerSearch: '', setReaderSearch: noop, setReaderSearchActive: noop,
+3 -3
View File
@@ -40,7 +40,7 @@ export default function ReadingPlansPage() {
addCustomPlan, removeCustomPlan, updateCustomPlan,
openPlanReading, studyReadingPlanDay, studyBuildBusy,
readingPlanSyncStatus, readingPlanSyncError, retryReadingPlanSync,
readerTheme, setReaderTheme, readerTranslation,
readerTheme, setReaderTheme, readerTranslation, planTranslationPref,
} = useApp();
const [detail, setDetail] = useState(null); // { planId, progressId? }
@@ -62,7 +62,7 @@ export default function ReadingPlansPage() {
const cycleTheme = () => setReaderTheme(THEME_ORDER[(THEME_ORDER.indexOf(readerTheme) + 1) % THEME_ORDER.length]);
const actions = {
defaultTranslation: readerTranslation,
defaultTranslation: planTranslationPref && planTranslationPref !== 'same' ? planTranslationPref : readerTranslation,
setTranslation: setPlanTranslation,
toggleDay,
recalibrate: recalibratePace,
@@ -103,7 +103,7 @@ export default function ReadingPlansPage() {
<div className="min-h-screen bg-[var(--r-bg)] pb-16 text-[var(--r-text)]" style={themeVars(readerTheme)}>
<div className="sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)] font-sans">
<div className="mx-auto flex h-12 max-w-3xl items-center gap-1 px-2">
<button type="button" onClick={goHome} aria-label="Back to studies" className="flex h-9 w-9 items-center justify-center rounded-lg text-[var(--r-sub)] hover:bg-black/5"><ChevronLeft /></button>
<button type="button" onClick={goHome} aria-label="Back to home" className="flex h-9 items-center gap-0.5 rounded-lg pl-1 pr-2 text-[var(--r-sub)] hover:bg-black/5"><ChevronLeft /><span className="hidden text-xs sm:inline">Home</span></button>
<h1 className="mx-auto text-sm font-medium">Reading plans</h1>
{streak.current > 0 && <span className="text-xs text-[var(--r-accent)]">{streak.current} day streak</span>}
<button type="button" onClick={cycleTheme} className="ml-2 rounded-lg px-2 py-1.5 text-xs text-[var(--r-sub)] hover:bg-black/5" title="Change theme">{READER_THEMES[readerTheme]?.label}</button>
+82
View File
@@ -1,6 +1,9 @@
import { useState } from 'react';
import { useApp } from '../context/AppContext.js';
import PageShell from '../components/study/PageShell.jsx';
import { READER_TRANSLATIONS } from '../hooks/useBibleReader.js';
import { COMMENTARIES } from '../hooks/useVerseStudy.js';
import { READER_THEMES } from '../components/reader/readerThemes.js';
import {
startMfaSetup,
confirmMfaSetup,
@@ -12,10 +15,46 @@ import {
} from '../syncService.js';
import { exportJournalDocx, printJournalPdf } from '../utils/journalExport.js';
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)]';
// One labelled preference: what it is, whether it follows you between devices, and the control.
function PrefRow({ label, hint, synced, children }) {
return (
<div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-2 border-t border-[var(--r-line)] py-3 first:border-t-0 first:pt-0">
<div className="min-w-0 flex-1 basis-56">
<p className="text-sm font-medium">{label}</p>
<p className="text-xs text-[var(--r-sub)]">{hint} <span className="whitespace-nowrap">· {synced ? 'Synced to your account' : 'This device only'}</span></p>
</div>
{children}
</div>
);
}
function Segmented({ label, options, value, onChange }) {
return (
<div role="group" aria-label={label} className="flex overflow-hidden rounded-xl border border-[var(--r-line)]">
{options.map(([id, text]) => (
<button key={String(id)} type="button" aria-pressed={value === id} onClick={() => onChange(id)}
className={`px-3 py-1.5 text-sm ${value === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{text}</button>
))}
</div>
);
}
export default function SettingsPage() {
const {
authUser, setAuthUser,
goHome,
readerTheme, setReaderTheme,
readerTranslation, setReaderTranslation,
planTranslationPref, setPlanTranslationPref,
startPagePref, setStartPagePref,
readerLayout, setReaderLayout,
readerFontSize, setReaderFontSize,
readerWide, setReaderWide,
readerCommentary, setReaderCommentary,
audioNarrator, setAudioNarrator,
} = useApp();
const [mfaSetup, setMfaSetup] = useState(null); // { secret, qrCodeDataUrl } while setup is in progress
const [mfaSetupCode, setMfaSetupCode] = useState('');
@@ -179,6 +218,49 @@ export default function SettingsPage() {
</p>
</section>
<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>
<PrefRow label="Home page" hint="Where the app opens, and where the Home buttons take you." synced>
<Segmented label="Home page" options={[['studies', 'My Studies'], ['reader', 'Bible Reader'], ['plans', 'Reading Plans']]} value={startPagePref} onChange={setStartPagePref} />
</PrefRow>
<PrefRow label="Theme" hint="Paper, sepia or dark." synced>
<Segmented label="Theme" options={Object.entries(READER_THEMES).map(([id, t]) => [id, t.label])} value={readerTheme} onChange={setReaderTheme} />
</PrefRow>
<PrefRow label="Default Bible" hint="What the Reader opens in." synced>
<select aria-label="Default Bible" value={readerTranslation} onChange={(e) => setReaderTranslation(e.target.value)} className={selectCls}>
{READER_TRANSLATIONS.map((t) => <option key={t.id} value={t.id}>{t.id} · {t.name}</option>)}
</select>
</PrefRow>
<PrefRow label="New reading plans" hint="The Bible a plan starts in. You can change it per plan." synced>
<select aria-label="New reading plans" value={planTranslationPref} onChange={(e) => setPlanTranslationPref(e.target.value)} className={selectCls}>
<option value="same">Same as my default Bible</option>
{READER_TRANSLATIONS.map((t) => <option key={t.id} value={t.id}>{t.id} · {t.name}</option>)}
</select>
</PrefRow>
<PrefRow label="Verse layout" hint="Verses run together like a page, or one to a line." synced>
<Segmented label="Verse layout" options={[['flow', 'Flowing'], ['verses', 'One per line']]} value={readerLayout} onChange={setReaderLayout} />
</PrefRow>
<PrefRow label="Commentary" hint="The one shown first in the Reader and the Study stage." synced>
<select aria-label="Commentary" value={readerCommentary} onChange={(e) => setReaderCommentary(e.target.value)} className={selectCls}>
{COMMENTARIES.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</PrefRow>
<PrefRow label="Text size" hint="Reader text.">
<Segmented label="Text size" options={FONT_SIZES.map(([l, n]) => [n, l])} value={readerFontSize} onChange={setReaderFontSize} />
</PrefRow>
<PrefRow label="Reading width" hint="One column, or two on a wide screen.">
<Segmented label="Reading width" options={[[false, 'Column'], [true, 'Two columns']]} value={readerWide} onChange={setReaderWide} />
</PrefRow>
<PrefRow label="Audio narrator" hint="For the Berean Standard Bible audio.">
<select aria-label="Audio narrator" value={audioNarrator} onChange={(e) => setAudioNarrator(e.target.value)} className={selectCls}>
<option value="david">David</option>
<option value="hays">Hays</option>
<option value="souer">Souer</option>
</select>
</PrefRow>
</section>
<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>