Put every preference in Settings; same header on every page
Build and Push Docker Image / build-and-push (push) Successful in 5m7s
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:
1 parent
a2185ba1dd
commit
512c6b6889
7 files changed
+204
-1
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
Reference in new issue
Block a user