Put every preference in Settings; same header on every page
Build and Push Docker Image / build-and-push (push) Successful in 5m7s

- Settings > Preferences: typeface, line spacing, verse numbers, highlight colour names,
  My Studies grid/list and sort, verse-of-the-day Bible and picture, commentary sources, study templates
- Header uses one fixed width and reserves scrollbar space so the nav sits in the same place on every page

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-06 14:10:12 -04:00
1 parent a2185ba1dd
commit 512c6b6889
7 files changed
+204 -1

No files matched your search

+121
View File
@@ -0,0 +1,121 @@
// Settings → Preferences: the choices that used to be reachable only from the screen they affect
// (highlight colour names, the dashboard's layout, the verse of the day, saved study templates).
import { useEffect, useState } from 'react';
import { useApp } from '../../context/AppContext.js';
import { useLocalSetting } from '../../hooks/useLocalSetting.js';
import { useIqAvailable } from '../../hooks/useIq.js';
import { READER_TRANSLATIONS } from '../../utils/bibleText.js';
import { COLOR_NAMES, COLOR_SUGGESTIONS, HIGHLIGHT_COLORS } from '../../utils/highlightColors.js';
import { SCENES, loadPhotoList } from '../../utils/verseBackgrounds.js';
import { BACKGROUND_KEY } from '../VerseShareImage.jsx';
const card = 'rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6';
const field = '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)]';
function Row({ label, hint, children }) {
return (
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1.5 border-t border-[var(--r-line)] py-3 first:border-t-0">
<div className="min-w-0 flex-1 basis-48">
<p className="text-sm">{label}</p>
{hint && <p className="text-xs text-[var(--r-sub)]">{hint}</p>}
</div>
{children}
</div>
);
}
export function HighlightColours() {
const { readerColorLabels = {}, setReaderColorLabel } = useApp();
return (
<section aria-label="Highlight colours" className={card}>
<h2 className="text-base font-medium">Highlight colours</h2>
<p className="mb-3 mt-1 text-sm text-[var(--r-sub)]">Give each colour a meaning, such as “Promise” or “Command”. The names show in your Highlights list and exports, and sync to your other devices.</p>
{HIGHLIGHT_COLORS.map((c) => (
<div key={c} className="flex items-center gap-3 border-t border-[var(--r-line)] py-2 first:border-t-0">
<span aria-hidden="true" className="h-5 w-5 shrink-0 rounded-full border border-black/10" style={{ background: c }} />
<input aria-label={`Name for ${COLOR_NAMES[c].toLowerCase()} highlights`} value={readerColorLabels[c] ?? ''} maxLength={30}
onChange={(e) => setReaderColorLabel(c, e.target.value)} placeholder={COLOR_SUGGESTIONS[c] ?? COLOR_NAMES[c]} className={`${field} min-w-0 flex-1`} />
</div>
))}
</section>
);
}
export function DashboardSettings() {
const [view, setView] = useLocalSetting('homeView', 'grid');
const [sort, setSort] = useLocalSetting('homeSort', 'recent');
return (
<section aria-label="My Studies page" className={card}>
<h2 className="text-base font-medium">My Studies page</h2>
<Row label="Show studies as" hint="You can still switch on the page itself.">
<div role="group" aria-label="Show studies as" className="flex overflow-hidden rounded-xl border border-[var(--r-line)]">
{[['grid', 'Grid'], ['list', 'List']].map(([id, text]) => (
<button key={id} type="button" aria-pressed={view === id} onClick={() => setView(id)}
className={`px-3 py-2 text-sm ${view === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{text}</button>
))}
</div>
</Row>
<Row label="Sort by">
<select aria-label="Sort studies by" value={sort} onChange={(e) => setSort(e.target.value)} className={field}>
<option value="recent">Last edited</option>
<option value="title">Title</option>
<option value="passage">Passage</option>
</select>
</Row>
</section>
);
}
export function VerseOfDaySettings() {
const on = useIqAvailable();
const { readerTranslation } = useApp();
const [translation, setTranslation] = useLocalSetting('votd-translation', '');
const [background, setBackground] = useLocalSetting(BACKGROUND_KEY, 'auto');
const [photos, setPhotos] = useState([]);
useEffect(() => { let live = true; loadPhotoList().then((list) => { if (live) setPhotos(list); }); return () => { live = false; }; }, []);
if (!on) return null;
const shown = READER_TRANSLATIONS.some((t) => t.id === translation) ? translation : '';
return (
<section aria-label="Verse of the day" className={card}>
<h2 className="text-base font-medium">Verse of the day</h2>
<p className="mb-1 mt-1 text-sm text-[var(--r-sub)]">The card on your My Studies page. Turn on the daily reminder under Notifications.</p>
<Row label="Bible" hint="Today’s verse is chosen for everyone; its words come from the Bible you pick.">
<select aria-label="Verse of the day Bible" value={shown} onChange={(e) => setTranslation(e.target.value)} className={field}>
<option value="">Same as my default Bible ({readerTranslation})</option>
{READER_TRANSLATIONS.map((t) => <option key={t.id} value={t.id}>{t.id} · {t.name}</option>)}
</select>
</Row>
<Row label="Picture behind the verse">
<select aria-label="Verse of the day picture" value={background} onChange={(e) => setBackground(e.target.value)} className={field}>
<option value="auto">A different picture each day</option>
<option value="plain">Plain</option>
<optgroup label="Scenes">{SCENES.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}</optgroup>
{photos.length > 0 && <optgroup label="Your photos">{photos.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}</optgroup>}
</select>
</Row>
</section>
);
}
export function StudyTemplates() {
const { userTemplates = [], deleteUserTemplate } = useApp();
return (
<section aria-label="Study templates" className={card}>
<h2 className="text-base font-medium">Study templates</h2>
<p className="mb-2 mt-1 text-sm text-[var(--r-sub)]">Templates you saved from a study’s Setup stage. They sync to your other devices.</p>
{userTemplates.length === 0 ? (
<p className="text-sm text-[var(--r-sub)]">None saved yet.</p>
) : (
<ul className="divide-y divide-[var(--r-line)]">
{userTemplates.map((t) => (
<li key={t.id} className="flex items-center justify-between gap-3 py-2">
<span className="min-w-0"><span className="text-sm">{t.name}</span>{t.description && <span className="block truncate text-xs text-[var(--r-sub)]">{t.description}</span>}</span>
<button type="button" onClick={() => { if (window.confirm(`Delete the template “${t.name}”? Studies already made from it keep their notes.`)) deleteUserTemplate(t.id); }}
className="shrink-0 rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5">Delete</button>
</li>
))}
</ul>
)}
</section>
);
}
+1 -1
View File
@@ -95,7 +95,7 @@ export default function PageShell({ title, onBack, actions, active = null, maxWi
<div className="min-h-dvh bg-[var(--r-bg)] font-sans text-[var(--r-text)]" style={themeVars(readerTheme)}>
<a href="#content" className="skip-link">Skip to content</a>
<header className="safe-top sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)]">
<div className={`mx-auto flex h-12 ${maxWidth} items-center gap-2 px-3 sm:gap-4 sm:px-4`}>
<div className="mx-auto flex h-12 max-w-6xl items-center gap-2 px-3 sm:gap-4 sm:px-4">
<button type="button" onClick={goHome} className="-m-2 flex shrink-0 items-center gap-2 p-2" aria-label="Bible Study home">
<img src="/logo.svg" alt="" className="h-6 w-6" />
<span className="hidden text-sm font-medium sm:inline">Bible Study</span>
@@ -35,3 +35,12 @@ test('on a wide screen the destinations stay in the header', () => {
expect(nav.closest('header')).not.toBeNull();
expect(screen.getAllByRole('button', { name: 'Account menu' })).toHaveLength(1);
});
test('the header is the same width on every page, whatever width the page content is', () => {
phone(false);
const { container, rerender } = render(<PageShell title="A" maxWidth="max-w-3xl"><p>x</p></PageShell>);
const inner = () => container.querySelector('header > div');
expect(inner().className).toContain('max-w-6xl');
rerender(<PageShell title="B" maxWidth="max-w-2xl"><p>x</p></PageShell>);
expect(inner().className).toContain('max-w-6xl');
});