Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f0256be06 | ||
|
|
bb80623c45 | ||
|
|
46a6e3bf7a | ||
|
|
49a0eb9d0b |
No files matched your search
@@ -391,3 +391,53 @@ describe('Window title', () => {
|
||||
await waitFor(() => expect(document.title).toMatch(/^[A-Za-z0-9 ]+ \d+ · Bible Study$/));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Login page', () => {
|
||||
async function openLogin() {
|
||||
stubServer({ user: null });
|
||||
render(<App />);
|
||||
return screen.findByRole('button', { name: /^sign in$/i });
|
||||
}
|
||||
|
||||
test('tells you what the app can do, in groups', async () => {
|
||||
await openLogin();
|
||||
expect(screen.getByRole('heading', { level: 1, name: /read it\. study it\./i })).toBeInTheDocument();
|
||||
for (const group of ['Read', 'Study', 'Teach', 'Everywhere']) expect(screen.getByRole('region', { name: group })).toBeInTheDocument();
|
||||
expect(screen.getByText('A real outline')).toBeInTheDocument();
|
||||
expect(screen.getByText('Compare them')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('switches between signing in and creating an account', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogin();
|
||||
expect(screen.getByRole('heading', { name: 'Sign in' })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: /need an account/i }));
|
||||
expect(screen.getByRole('heading', { name: 'Create an account' })).toBeInTheDocument();
|
||||
expect(screen.getByText(/at least 8 characters/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^create account$/i })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: /already have an account/i }));
|
||||
expect(screen.getByRole('heading', { name: 'Sign in' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a theme can be picked before signing in', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogin();
|
||||
await user.click(screen.getByRole('button', { name: 'Dark' }));
|
||||
expect(screen.getByRole('button', { name: 'Dark' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(localStorage.getItem('reader-theme')).toBe('dark');
|
||||
});
|
||||
|
||||
test('a wrong password shows the error', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.stubGlobal('fetch', vi.fn((url, opts = {}) => {
|
||||
if (url === '/api/auth/me') return json(401, {});
|
||||
if (url === '/api/auth/login') return json(401, { error: 'Incorrect email or password.' });
|
||||
return Promise.resolve({ ok: false });
|
||||
}));
|
||||
render(<App />);
|
||||
await user.type(await screen.findByLabelText(/email/i), 'a@b.co');
|
||||
await user.type(screen.getByLabelText(/^password/i), 'wrong-password');
|
||||
await user.click(screen.getByRole('button', { name: /^sign in$/i }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(/incorrect email or password/i);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { ToastProvider, useToast } from './components/Toasts.jsx';
|
||||
import CommandPalette from './components/CommandPalette.jsx';
|
||||
import ShortcutsHelp from './components/ShortcutsHelp.jsx';
|
||||
import SharedOutlineView from './components/SharedOutlineView.jsx';
|
||||
import Tooltips from './components/Tooltips.jsx';
|
||||
import { useAudio } from './hooks/useAudio.js';
|
||||
import { useAuth } from './hooks/useAuth.js';
|
||||
import { useBibleReader } from './hooks/useBibleReader.js';
|
||||
@@ -437,6 +438,7 @@ const AppInner = () => {
|
||||
</Suspense>
|
||||
{paletteOpen && <CommandPalette onClose={() => setPaletteOpen(false)} />}
|
||||
{helpOpen && <ShortcutsHelp onClose={() => setHelpOpen(false)} />}
|
||||
<Tooltips theme={reader.readerTheme} />
|
||||
</AppContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// Hover (and keyboard-focus) tooltips for the whole app, without touching every button:
|
||||
// - anything with data-tip="…" shows that text (used for shortcut hints like "Search (/)")
|
||||
// - anything with title="…" shows it too, in the app's style and without the browser's long delay
|
||||
// - an icon-only button (no visible words) shows its aria-label
|
||||
// Touch screens have no hover, so nothing appears there.
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { themeVars } from './reader/readerThemes.js';
|
||||
|
||||
const SHOW_DELAY = 450;
|
||||
const SELECTOR = '[data-tip], [title], button[aria-label], a[aria-label], [role="button"][aria-label], [role="tab"][aria-label]';
|
||||
|
||||
const canHover = () => (typeof window.matchMedia === 'function' ? window.matchMedia('(hover: hover)').matches : true);
|
||||
|
||||
// The words a tooltip should show for an element, or '' for none. A title is moved to data-tip the first
|
||||
// time (so the browser's own tooltip doesn't also appear); a title on a button with no other name becomes
|
||||
// its aria-label so screen readers keep it.
|
||||
export function tipFor(el) {
|
||||
if (!(el instanceof HTMLElement)) return '';
|
||||
if (el.hasAttribute('title')) {
|
||||
const title = el.getAttribute('title').trim();
|
||||
if (title) {
|
||||
if (!el.getAttribute('aria-label') && el.textContent.replace(/\s+/g, '').length <= 2) el.setAttribute('aria-label', title);
|
||||
if (!el.hasAttribute('data-tip')) el.setAttribute('data-tip', title);
|
||||
}
|
||||
el.removeAttribute('title');
|
||||
}
|
||||
const tip = el.getAttribute('data-tip');
|
||||
if (tip) return tip;
|
||||
// Icon-only controls (little or no text of their own) explain themselves with their label
|
||||
const label = el.getAttribute('aria-label');
|
||||
const visible = el.textContent.replace(/\s+/g, '').length;
|
||||
return label && visible <= 2 ? label : '';
|
||||
}
|
||||
|
||||
export default function Tooltips({ theme }) {
|
||||
const [tip, setTip] = useState(null); // { text, x, y, below }
|
||||
const timer = useRef(null);
|
||||
const current = useRef(null);
|
||||
const box = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const hide = () => {
|
||||
window.clearTimeout(timer.current);
|
||||
if (current.current) current.current.removeAttribute('aria-describedby');
|
||||
current.current = null;
|
||||
setTip(null);
|
||||
};
|
||||
const show = (el, text) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
current.current = el;
|
||||
el.setAttribute('aria-describedby', 'app-tooltip');
|
||||
setTip({ text, x: r.left + r.width / 2, y: r.top, bottom: r.bottom });
|
||||
};
|
||||
const find = (node) => (node instanceof Element ? node.closest(SELECTOR) : null);
|
||||
|
||||
const onOver = (e) => {
|
||||
if (!canHover()) return;
|
||||
const el = find(e.target);
|
||||
if (!el || el === current.current) return;
|
||||
hide();
|
||||
const text = tipFor(el);
|
||||
if (!text || el.disabled) return;
|
||||
timer.current = window.setTimeout(() => show(el, text), SHOW_DELAY);
|
||||
};
|
||||
const onOut = (e) => {
|
||||
const el = find(e.target);
|
||||
if (!el || (e.relatedTarget instanceof Node && el.contains(e.relatedTarget))) return;
|
||||
hide();
|
||||
};
|
||||
const onFocus = (e) => {
|
||||
const el = find(e.target);
|
||||
if (!el || !el.matches(':focus-visible')) return; // only keyboard focus, not a mouse click
|
||||
const text = tipFor(el);
|
||||
if (text) { window.clearTimeout(timer.current); show(el, text); }
|
||||
};
|
||||
const onKey = (e) => { if (e.key === 'Escape') hide(); };
|
||||
|
||||
document.addEventListener('pointerover', onOver);
|
||||
document.addEventListener('pointerout', onOut);
|
||||
document.addEventListener('pointerdown', hide, true);
|
||||
document.addEventListener('focusin', onFocus);
|
||||
document.addEventListener('focusout', hide);
|
||||
document.addEventListener('keydown', onKey);
|
||||
window.addEventListener('scroll', hide, true);
|
||||
window.addEventListener('resize', hide);
|
||||
return () => {
|
||||
window.clearTimeout(timer.current);
|
||||
document.removeEventListener('pointerover', onOver);
|
||||
document.removeEventListener('pointerout', onOut);
|
||||
document.removeEventListener('pointerdown', hide, true);
|
||||
document.removeEventListener('focusin', onFocus);
|
||||
document.removeEventListener('focusout', hide);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
window.removeEventListener('scroll', hide, true);
|
||||
window.removeEventListener('resize', hide);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!tip) return null;
|
||||
// Centred over the control, kept inside the window, and flipped below it when there's no room above
|
||||
const width = box.current?.offsetWidth ?? 0;
|
||||
const left = Math.max(8, Math.min(tip.x - width / 2, window.innerWidth - width - 8));
|
||||
const flip = tip.y < 40;
|
||||
return (
|
||||
<div
|
||||
ref={box} id="app-tooltip" role="tooltip" style={{ ...themeVars(theme), left, top: flip ? tip.bottom + 8 : tip.y - 8, transform: flip ? 'none' : 'translateY(-100%)' }}
|
||||
className="pointer-events-none fixed z-[120] max-w-[16rem] rounded-lg bg-[var(--r-text)] px-2.5 py-1.5 font-sans text-xs leading-snug text-[var(--r-bg)] shadow-lg"
|
||||
>
|
||||
{tip.text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import { vi, beforeEach, afterEach, test, expect } from 'vitest';
|
||||
import Tooltips, { tipFor } from './Tooltips.jsx';
|
||||
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
const over = (el) => act(() => { fireEvent.pointerOver(el); vi.advanceTimersByTime(600); });
|
||||
|
||||
function setup() {
|
||||
render(
|
||||
<>
|
||||
<button aria-label="Search" data-tip="Search (/)">🔍</button>
|
||||
<button aria-label="Close">×</button>
|
||||
<button title="Out a level (Shift+Tab)">←</button>
|
||||
<button aria-label="Save changes">Save changes</button>
|
||||
<button disabled aria-label="Nothing">·</button>
|
||||
<Tooltips theme="paper" />
|
||||
</>,
|
||||
);
|
||||
}
|
||||
|
||||
test('shows a data-tip after a short pause, and hides when the pointer leaves', () => {
|
||||
setup();
|
||||
const btn = screen.getByLabelText('Search');
|
||||
over(btn);
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent('Search (/)');
|
||||
expect(btn).toHaveAttribute('aria-describedby', 'app-tooltip');
|
||||
act(() => { fireEvent.pointerOut(btn); });
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
expect(btn).not.toHaveAttribute('aria-describedby');
|
||||
});
|
||||
|
||||
test('does not appear before the pause', () => {
|
||||
setup();
|
||||
act(() => { fireEvent.pointerOver(screen.getByLabelText('Search')); vi.advanceTimersByTime(100); });
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('an icon-only button shows its label', () => {
|
||||
setup();
|
||||
over(screen.getByLabelText('Close'));
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent('Close');
|
||||
});
|
||||
|
||||
test('a title is shown in the app style and the browser\'s own tooltip is turned off', () => {
|
||||
setup();
|
||||
const btn = screen.getByText('←');
|
||||
over(btn);
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent('Out a level (Shift+Tab)');
|
||||
expect(btn).not.toHaveAttribute('title');
|
||||
expect(btn).toHaveAttribute('aria-label', 'Out a level (Shift+Tab)'); // a screen reader still gets its name
|
||||
});
|
||||
|
||||
test('a button that already says what it is gets no tooltip, and neither does a disabled one', () => {
|
||||
setup();
|
||||
over(screen.getByText('Save changes'));
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
over(screen.getByLabelText('Nothing'));
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('clicking, scrolling or Escape dismisses it', () => {
|
||||
setup();
|
||||
const btn = screen.getByLabelText('Search');
|
||||
over(btn);
|
||||
act(() => { fireEvent.pointerDown(btn); });
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
over(btn);
|
||||
act(() => { fireEvent.keyDown(document, { key: 'Escape' }); });
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
over(btn);
|
||||
act(() => { fireEvent.scroll(window); });
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('nothing shows on a device without hover (touch)', () => {
|
||||
vi.stubGlobal('matchMedia', () => ({ matches: false }));
|
||||
setup();
|
||||
over(screen.getByLabelText('Search'));
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test('tipFor ignores things that are not elements', () => {
|
||||
expect(tipFor(null)).toBe('');
|
||||
expect(tipFor(document.createTextNode('x'))).toBe('');
|
||||
});
|
||||
@@ -9,9 +9,9 @@ export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks,
|
||||
return (
|
||||
<div className="safe-top 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="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={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" data-tip="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">
|
||||
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" data-tip="Go to a book and chapter (G)">
|
||||
<span className="truncate">{title}</span><ChevronDown />
|
||||
</button>
|
||||
{onDoneDrawing ? (
|
||||
@@ -21,12 +21,12 @@ export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks,
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" onClick={onSearch} className={iconBtn} aria-label="Search"><SearchIcon /></button>
|
||||
<button type="button" onClick={onBookmarks} className={`${iconBtn} relative ${bookmarkCount ? 'text-[var(--r-accent)]' : ''}`} aria-label="Bookmarks">
|
||||
<button type="button" onClick={onSearch} className={iconBtn} aria-label="Search" data-tip="Search (/)"><SearchIcon /></button>
|
||||
<button type="button" onClick={onBookmarks} className={`${iconBtn} relative ${bookmarkCount ? 'text-[var(--r-accent)]' : ''}`} aria-label="Bookmarks" data-tip="Bookmarks and highlights">
|
||||
<BookmarkIcon filled={bookmarkCount > 0} />
|
||||
</button>
|
||||
<button type="button" onClick={onCompare} className={`${iconBtn} ${compareActive ? 'text-[var(--r-accent)]' : ''}`} aria-label="Compare translations" aria-pressed={!!compareActive} title="Compare translations"><CompareIcon /></button>
|
||||
<button type="button" onClick={onSettings} className={`${iconBtn} w-10 text-sm font-medium`} aria-label="Reading settings">Aa</button>
|
||||
<button type="button" onClick={onCompare} className={`${iconBtn} ${compareActive ? 'text-[var(--r-accent)]' : ''}`} aria-label="Compare translations" aria-pressed={!!compareActive} data-tip="Compare translations (C)"><CompareIcon /></button>
|
||||
<button type="button" onClick={onSettings} className={`${iconBtn} w-10 text-sm font-medium`} aria-label="Reading settings" data-tip="Text size, typeface and theme (,)">Aa</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -42,11 +42,11 @@ export function BottomBar({
|
||||
return (
|
||||
<div className="fixed inset-x-0 bottom-0 z-30 border-t border-[var(--r-line)] bg-[var(--r-bar)] pb-[env(safe-area-inset-bottom)] font-sans">
|
||||
<div className="mx-auto flex h-14 max-w-4xl items-center gap-0.5 px-2">
|
||||
<button type="button" onClick={onPrev} disabled={prevDisabled} className={iconBtn} aria-label="Previous chapter"><ChevronLeft /></button>
|
||||
<button type="button" onClick={onAudio} className={`${iconBtn} ${audioActive ? active : ''}`} aria-label="Listen"><HeadphonesIcon /></button>
|
||||
<button type="button" onClick={onPen} className={iconBtn} aria-label="Draw notes"><PenIcon /></button>
|
||||
<button type="button" onClick={onCrossRefs} className={`${iconBtn} ${crossRefsOn ? active : ''}`} aria-label="Show cross-references under verses"><LinkIcon /></button>
|
||||
<button type="button" onClick={onFocus} className={iconBtn} aria-label="Hide controls"><FocusIcon /></button>
|
||||
<button type="button" onClick={onPrev} disabled={prevDisabled} className={iconBtn} aria-label="Previous chapter" data-tip="Previous chapter (←)"><ChevronLeft /></button>
|
||||
<button type="button" onClick={onAudio} className={`${iconBtn} ${audioActive ? active : ''}`} aria-label="Listen" data-tip="Listen to this chapter"><HeadphonesIcon /></button>
|
||||
<button type="button" onClick={onPen} className={iconBtn} aria-label="Draw notes" data-tip="Draw notes on this page"><PenIcon /></button>
|
||||
<button type="button" onClick={onCrossRefs} className={`${iconBtn} ${crossRefsOn ? active : ''}`} aria-label="Show cross-references under verses" data-tip="Show cross-references under each verse"><LinkIcon /></button>
|
||||
<button type="button" onClick={onFocus} className={iconBtn} aria-label="Hide controls" data-tip="Hide the controls (F)"><FocusIcon /></button>
|
||||
<div className="mx-auto" />
|
||||
{primary && (
|
||||
<button type="button" onClick={primary.onClick}
|
||||
@@ -54,7 +54,7 @@ export function BottomBar({
|
||||
{primary.done ? null : <CheckIcon />}{primary.label}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={onNext} disabled={nextDisabled} className={iconBtn} aria-label="Next chapter"><ChevronRight /></button>
|
||||
<button type="button" onClick={onNext} disabled={nextDisabled} className={iconBtn} aria-label="Next chapter" data-tip="Next chapter (→)"><ChevronRight /></button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function ReaderText({
|
||||
title, translation, translationId, notice, verses, loading, error, layout, fontSize, wide,
|
||||
font = 'serif', spacing = 'normal', showVerseNumbers = true, parallel = null, showDiff = true,
|
||||
bookmarks, bookKey, chapter, selectedVerse, onSelectVerse, renderVerseText, searchLower,
|
||||
crossRefs, showCrossRefs, onSelectionEnd, footer,
|
||||
crossRefs, showCrossRefs, onOpenCrossRef, onSelectionEnd, footer,
|
||||
}) {
|
||||
// How much the second Bible's wording differs from the first, over the chapter and verse by verse
|
||||
const diff = useMemo(
|
||||
@@ -56,7 +56,11 @@ export default function ReaderText({
|
||||
const chips = (v) => showCrossRefs && crossRefs?.[v.number]?.length > 0 && (
|
||||
<span className="ml-1 inline-flex flex-wrap gap-1 align-baseline font-sans">
|
||||
{crossRefs[v.number].slice(0, 4).map((ref, i) => (
|
||||
<span key={i} className="rounded-full border border-[var(--r-line)] px-1.5 py-px text-[10px] text-[var(--r-sub)]">{formatCrossRef(ref)}</span>
|
||||
// Opens that passage in the side panel; stopPropagation so the verse itself isn't also toggled
|
||||
<button key={i} type="button" onClick={(e) => { e.stopPropagation(); onOpenCrossRef?.(v.number, ref); }}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className="rounded-full border border-[var(--r-line)] px-1.5 py-px text-[10px] text-[var(--r-sub)] hover:border-[var(--r-accent)] hover:text-[var(--r-accent)]"
|
||||
data-tip={`Read ${formatCrossRef(ref)} in the side panel`}>{formatCrossRef(ref)}</button>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The single home for per-verse tools: original words, cross-references,
|
||||
// commentary, and people & places, plus bookmark / copy.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Sheet from './Sheet.jsx';
|
||||
import { CommentaryTab, PeoplePlacesTab } from '../StudyPanel.jsx';
|
||||
import { BookmarkIcon, CopyIcon } from '../../utils/bibleUtils.jsx';
|
||||
@@ -8,6 +8,7 @@ import { formatCrossRef, speakOriginalWord } from '../../utils/wordStudy.js';
|
||||
import { READER_TRANSLATIONS, fetchChapter } from '../../utils/bibleText.js';
|
||||
import { diffVerses, differenceLabel } from '../../utils/textDiff.js';
|
||||
import { DiffText } from './DiffText.jsx';
|
||||
import { HIGHLIGHT_COLORS, colorTitle } from '../../utils/highlightColors.js';
|
||||
|
||||
const TABS = [['commentary', 'Commentary'], ['compare', 'Compare'], ['original', 'Original'], ['refs', 'Cross-refs'], ['people', 'People & places']];
|
||||
|
||||
@@ -37,18 +38,59 @@ function OriginalTab({ words }) {
|
||||
);
|
||||
}
|
||||
|
||||
function RefsTab({ refs, loading, onJump }) {
|
||||
// A cross-referenced passage shown in the panel: its words in the translation being read, without
|
||||
// leaving the chapter. "Open chapter" goes there when you want more.
|
||||
function RefPreview({ refItem, translation, onOpen, onClose }) {
|
||||
const [state, setState] = useState({ key: '', verses: [], error: '' });
|
||||
const key = `${translation}/${refItem.book}/${refItem.chapter}`;
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
fetchChapter(translation, refItem.book, refItem.chapter, { signal: controller.signal })
|
||||
.then(({ verses }) => setState({ key, verses, error: '' }))
|
||||
.catch((err) => { if (!controller.signal.aborted) setState({ key, verses: [], error: err.message || 'Unable to load that passage.' }); });
|
||||
return () => controller.abort();
|
||||
}, [key]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const end = refItem.endVerse && refItem.endVerse > refItem.verse ? refItem.endVerse : refItem.verse;
|
||||
const picked = state.key === key ? state.verses.filter((v) => v.number >= refItem.verse && v.number <= end) : [];
|
||||
const loading = state.key !== key;
|
||||
return (
|
||||
<div className="mb-3 rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-3" role="region" aria-label={`Cross-reference ${formatCrossRef(refItem)}`}>
|
||||
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium">{formatCrossRef(refItem)} <span className="text-xs font-normal text-[var(--r-sub)]">{translation}</span></p>
|
||||
<button type="button" onClick={onClose} aria-label="Close cross-reference" className="rounded p-1 text-[var(--r-sub)] hover:bg-black/5">×</button>
|
||||
</div>
|
||||
{loading ? <p className="text-sm text-[var(--r-sub)]">Loading…</p>
|
||||
: state.error ? <p className="text-sm text-[var(--r-danger,#b42318)]">{state.error}</p>
|
||||
: picked.length === 0 ? <p className="text-sm text-[var(--r-sub)]">Those verses weren’t found.</p>
|
||||
: (
|
||||
<p className="font-serif text-[15px] leading-relaxed">
|
||||
{picked.map((v) => <span key={v.number}>{picked.length > 1 && <sup className="mr-0.5 font-sans text-[0.65em] text-[var(--r-sub)]">{v.number}</sup>}{v.text}{' '}</span>)}
|
||||
</p>
|
||||
)}
|
||||
<button type="button" onClick={() => onOpen(refItem.book, refItem.chapter, refItem.verse)} className="mt-2 text-xs font-medium text-[var(--r-accent)] hover:underline">Open chapter</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sameRef = (a, b) => a && b && a.book === b.book && a.chapter === b.chapter && a.verse === b.verse && (a.endVerse ?? a.verse) === (b.endVerse ?? b.verse);
|
||||
|
||||
function RefsTab({ refs, loading, active, onPick, preview }) {
|
||||
if (loading && !refs) return <p className="text-sm text-[var(--r-sub)]">Loading…</p>;
|
||||
if (!refs?.length) return <p className="text-sm text-[var(--r-sub)]">No cross-references for this verse.</p>;
|
||||
return (
|
||||
<>
|
||||
{preview}
|
||||
<p className="mb-1.5 text-[11px] text-[var(--r-sub)]">Tap one to read it here.</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{refs.slice(0, 20).map((ref, i) => (
|
||||
<button key={i} type="button" onClick={() => onJump(ref.book, ref.chapter, ref.verse)}
|
||||
className="rounded-full border border-[var(--r-line)] px-2.5 py-1 text-xs hover:bg-black/5">
|
||||
<button key={i} type="button" onClick={() => onPick(ref)} aria-pressed={sameRef(active, ref)}
|
||||
className={`rounded-full border px-2.5 py-1 text-xs ${sameRef(active, ref) ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] hover:bg-black/5'}`}>
|
||||
{formatCrossRef(ref)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -99,10 +141,27 @@ function CompareTab({ book, chapter, verse, current }) {
|
||||
|
||||
export default function VerseSheet({
|
||||
verse, book, bookName, chapter, translation, words, crossRefs, crossRefsLoading, onLoadCrossRefs,
|
||||
bookmarkColor, onToggleBookmark, onCycleColor, onJump, onClose,
|
||||
bookmarkColor, onToggleBookmark, onCycleColor, onJump, onClose, request,
|
||||
verseHighlights = [], colorLabels = {}, onHighlightVerse, onClearVerseHighlights,
|
||||
}) {
|
||||
const [tab, setTab] = useState('commentary');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [preview, setPreview] = useState(null); // the cross-reference being read in the panel
|
||||
const appliedRequest = useRef(0);
|
||||
// Leaving this verse closes the preview; a cross-reference tapped in the text opens it here. (Tracks the
|
||||
// previous verse so React's double-run of effects in development can't clear a preview it just opened.)
|
||||
const previousVerse = useRef(null);
|
||||
useEffect(() => {
|
||||
const verseChanged = previousVerse.current !== verse?.number;
|
||||
previousVerse.current = verse?.number;
|
||||
if (request && request.verse === verse?.number && request.n !== appliedRequest.current) {
|
||||
appliedRequest.current = request.n;
|
||||
setTab('refs');
|
||||
setPreview(request.ref);
|
||||
} else if (verseChanged) {
|
||||
setPreview(null);
|
||||
}
|
||||
}, [verse?.number, request?.n]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
useEffect(() => { if (verse && tab === 'refs') onLoadCrossRefs(); }, [verse?.number, tab]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
useEffect(() => { setCopied(false); }, [verse?.number]);
|
||||
if (!verse) return null;
|
||||
@@ -134,6 +193,27 @@ export default function VerseSheet({
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{onHighlightVerse && (
|
||||
// One-tap highlighting of the whole verse (to mark just some words, select them in the text)
|
||||
<div className="mb-3 flex flex-wrap items-center gap-1.5" role="group" aria-label="Highlight this verse">
|
||||
<span className="mr-1 text-xs text-[var(--r-sub)]">Highlight</span>
|
||||
{HIGHLIGHT_COLORS.map((color) => {
|
||||
const on = verseHighlights.some((h) => h.color === color && h.startOffset === 0 && h.endOffset >= verse.text.length);
|
||||
return (
|
||||
<button key={color} type="button" onClick={() => onHighlightVerse(color)} aria-pressed={on}
|
||||
aria-label={`Highlight verse ${colorTitle(color, colorLabels).toLowerCase()}`} data-tip={`${colorTitle(color, colorLabels)}${on ? ' — tap to remove' : ''}`}
|
||||
className={`h-6 w-6 rounded-full border-2 transition hover:scale-110 ${on ? 'border-[var(--r-text)]' : 'border-transparent ring-1 ring-black/10'}`}
|
||||
style={{ backgroundColor: color }}>
|
||||
{on && <span className="text-[10px] leading-none text-[#2b2620]" aria-hidden="true">✓</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{verseHighlights.length > 0 && (
|
||||
<button type="button" onClick={onClearVerseHighlights} className="ml-1 text-xs text-[var(--r-sub)] underline hover:text-[var(--r-text)]">Clear</button>
|
||||
)}
|
||||
<span className="basis-full text-[11px] text-[var(--r-sub)]">Or select words in the text to highlight just those.</span>
|
||||
</div>
|
||||
)}
|
||||
<div role="tablist" aria-label="About this verse" className="mb-3 flex gap-1.5 overflow-x-auto pb-1">
|
||||
{TABS.map(([id, label]) => (
|
||||
<button key={id} type="button" role="tab" aria-selected={tab === id} onClick={() => setTab(id)}
|
||||
@@ -145,7 +225,10 @@ export default function VerseSheet({
|
||||
{tab === 'commentary' && <CommentaryTab book={book} chapter={chapter} verse={verse.number} />}
|
||||
{tab === 'compare' && <CompareTab book={book} chapter={chapter} verse={verse.number} current={translation} />}
|
||||
{tab === 'original' && <OriginalTab words={words} />}
|
||||
{tab === 'refs' && <RefsTab refs={crossRefs} loading={crossRefsLoading} onJump={onJump} />}
|
||||
{tab === 'refs' && (
|
||||
<RefsTab refs={crossRefs} loading={crossRefsLoading} active={preview} onPick={setPreview}
|
||||
preview={preview && <RefPreview refItem={preview} translation={translation} onOpen={onJump} onClose={() => setPreview(null)} />} />
|
||||
)}
|
||||
{tab === 'people' && <PeoplePlacesTab book={book} chapter={chapter} verse={verse.number} />}
|
||||
</Sheet>
|
||||
);
|
||||
|
||||
@@ -83,7 +83,7 @@ export default function PageShell({ title, onBack, actions, active = null, maxWi
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{openPalette && (
|
||||
<button type="button" onClick={openPalette} aria-label="Search and go to"
|
||||
<button type="button" onClick={openPalette} aria-label="Search and go to" data-tip="Search studies, notes and passages (⌘K)"
|
||||
className="flex items-center gap-2 rounded-full border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-1 text-xs text-[var(--r-sub)] hover:bg-black/5">
|
||||
<SearchIcon /><span className="hidden sm:inline">Search</span><kbd className="hidden rounded border border-[var(--r-line)] px-1 text-[10px] md:inline">⌘K</kbd>
|
||||
</button>
|
||||
|
||||
+145
-38
@@ -1,8 +1,91 @@
|
||||
import { useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { themeVars } from '../components/reader/readerThemes.js';
|
||||
import { themeVars, READER_THEMES } from '../components/reader/readerThemes.js';
|
||||
import { loginUser, registerUser, verifyMfaLogin, logoutUser } from '../syncService.js';
|
||||
|
||||
// What the app does, in four groups. Everything listed here exists in the app.
|
||||
const FEATURES = [
|
||||
{
|
||||
title: 'Read',
|
||||
points: [
|
||||
['Six translations', 'BSB, ESV, NLT, CSB, NASB and NET, with audio and whole-Bible search.'],
|
||||
['Compare them', 'Read two side by side and see which words differ, verse by verse.'],
|
||||
['Mark it up', 'Highlight in colours you name, bookmark, and draw notes on the page.'],
|
||||
['Reading plans', 'Daily readings that open straight into the text, with streaks and reminders.'],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Study',
|
||||
points: [
|
||||
['Chunk a passage', 'Break it into sections and write Observe, Interpret and Apply notes.'],
|
||||
['Original languages', 'Greek and Hebrew word studies with pronunciation, from the interlinear.'],
|
||||
['Beside the text', 'Commentary, cross-references, and people and places for each chunk.'],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Teach',
|
||||
points: [
|
||||
['A real outline', 'Main points and sub-points for the whole study and for each chunk, with notes, verse text and minutes.'],
|
||||
['Scripts', 'Turn your notes into a script for a lesson, episode or sermon.'],
|
||||
['Share it', 'Word, PDF and Markdown files, or a read-only link anyone can open.'],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Everywhere',
|
||||
points: [
|
||||
['Your devices', 'Studies, marks and plans sync to your account, and nothing is overwritten silently.'],
|
||||
['Offline and backup', 'Save books to read without a connection, and download everything as one file.'],
|
||||
['Private', 'Your studies stay in your account, with optional two-factor sign-in.'],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Small, decorative pictures of two real views, so you can see what you're signing in to.
|
||||
function OutlinePreview() {
|
||||
const row = (label, text, depth = 0, tag = '') => (
|
||||
<div className="flex items-baseline gap-2" style={{ paddingLeft: `${depth * 1.1}rem` }}>
|
||||
<span className="w-5 shrink-0 text-right font-semibold text-[var(--r-accent)]">{label}</span>
|
||||
<span className={depth === 0 ? 'font-semibold' : ''}>
|
||||
{tag && <span className="mr-1.5 rounded-full border border-[var(--r-line)] px-1.5 py-px font-sans text-[8px] font-semibold uppercase tracking-widest text-[var(--r-accent)]">{tag}</span>}
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div aria-hidden="true" className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-4 font-serif text-[13px] leading-relaxed shadow-sm">
|
||||
<p className="mb-2 font-sans text-[9px] font-semibold uppercase tracking-[0.18em] text-[var(--r-accent)]">Outline</p>
|
||||
{row('I.', 'Greeting and the gospel hope')}
|
||||
{row('A.', 'Servant of God', 1, 'Illustration')}
|
||||
{row('B.', 'Apostle of Jesus Christ', 1)}
|
||||
{row('II.', 'Appoint elders in every town')}
|
||||
{row('A.', 'Blameless and faithful', 1, 'Application')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ComparePreview() {
|
||||
const mark = (t) => <span className="diff-hl">{t}</span>;
|
||||
return (
|
||||
<div aria-hidden="true" className="grid grid-cols-2 gap-3 rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-4 font-serif text-[13px] leading-relaxed shadow-sm">
|
||||
<div><p className="mb-1 font-sans text-[9px] font-semibold uppercase tracking-[0.18em] text-[var(--r-sub)]">BSB</p>The earth was {mark('formless and void')}, and darkness was over the surface of the deep.</div>
|
||||
<div><p className="mb-1 font-sans text-[9px] font-semibold uppercase tracking-[0.18em] text-[var(--r-sub)]">NET</p>The earth was {mark('without shape and empty')}, and darkness was over the surface of the deep.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemeSwitch() {
|
||||
const { readerTheme, setReaderTheme } = useApp();
|
||||
if (!setReaderTheme) return null;
|
||||
return (
|
||||
<div role="group" aria-label="Theme" className="flex overflow-hidden rounded-full border border-[var(--r-line)] text-xs">
|
||||
{Object.entries(READER_THEMES).map(([id, t]) => (
|
||||
<button key={id} type="button" aria-pressed={readerTheme === id} onClick={() => setReaderTheme(id)}
|
||||
className={`px-3 py-1.5 ${readerTheme === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AuthPage() {
|
||||
const { signIn, readerTheme } = useApp();
|
||||
const shell = { style: themeVars(readerTheme) };
|
||||
@@ -53,8 +136,8 @@ export default function AuthPage() {
|
||||
return (
|
||||
<div {...shell} className="flex min-h-dvh items-center justify-center bg-[var(--r-bg)] px-4 text-[var(--r-text)]">
|
||||
<div className="w-full max-w-sm rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-8">
|
||||
<p className="text-sm uppercase tracking-[0.24em] text-[var(--r-sub)]">Bible Study Project</p>
|
||||
<h1 className="mt-2 text-xl font-medium text-[var(--r-text)]">Two-factor verification</h1>
|
||||
<p className="flex items-center gap-2 text-sm uppercase tracking-[0.24em] text-[var(--r-sub)]"><img src="/logo.svg" alt="" className="h-6 w-6" />Bible Study Project</p>
|
||||
<h1 className="mt-3 text-xl font-medium text-[var(--r-text)]">Two-factor verification</h1>
|
||||
<p className="mt-2 text-sm text-[var(--r-sub)]">
|
||||
{authMfaUseBackup
|
||||
? 'Enter one of your saved backup codes.'
|
||||
@@ -109,39 +192,24 @@ export default function AuthPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div {...shell} className="flex min-h-dvh items-center justify-center bg-[var(--r-bg)] px-4 py-12 text-[var(--r-text)]">
|
||||
<div className="grid w-full max-w-5xl items-center gap-16 lg:grid-cols-2">
|
||||
{/* Branding / feature panel — hidden on small screens to keep the form front and center there */}
|
||||
<div className="hidden lg:block">
|
||||
<p className="text-sm uppercase tracking-[0.24em] text-[var(--r-sub)]">Bible Study Project</p>
|
||||
<h1 className="mt-4 text-3xl font-semibold leading-tight text-[var(--r-text)]">
|
||||
Deep Bible study,<br />one chunk at a time.
|
||||
</h1>
|
||||
<p className="mt-4 max-w-sm text-[var(--r-sub)]">
|
||||
Read Scripture in six translations, follow a reading plan into the text, and study a passage one chunk
|
||||
at a time with commentary, word studies, and cross-references beside it.
|
||||
<div {...shell} className="min-h-dvh bg-[var(--r-bg)] text-[var(--r-text)]">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-5 pt-5 sm:px-8">
|
||||
<p className="flex items-center gap-2.5 text-sm font-medium">
|
||||
<img src="/logo.svg" alt="" className="h-7 w-7" />
|
||||
<span>Bible Study Project</span>
|
||||
</p>
|
||||
<ul className="mt-8 space-y-3 text-sm text-[var(--r-sub)]">
|
||||
<li>Read in BSB, ESV, NLT, CSB, NASB, or NET, with audio and search</li>
|
||||
<li>Reading plans that open straight into the Reader</li>
|
||||
<li>Observe, Interpret, Apply notes with Greek & Hebrew word studies</li>
|
||||
<li>Turn your notes into a script, then export or share</li>
|
||||
<li>Your studies stay private to your account, with optional 2FA</li>
|
||||
</ul>
|
||||
<blockquote className="mt-8 border-l-2 border-[var(--r-line)] pl-4 text-sm italic text-[var(--r-sub)]">
|
||||
"Make every effort to present yourself approved to God, an unashamed workman who accurately
|
||||
handles the word of truth."
|
||||
<footer className="mt-1 not-italic text-[var(--r-sub)]">— 2 Timothy 2:15 (BSB)</footer>
|
||||
</blockquote>
|
||||
<ThemeSwitch />
|
||||
</div>
|
||||
|
||||
{/* Sign in / register form */}
|
||||
<div className="flex justify-center lg:justify-start">
|
||||
<div className="w-full max-w-sm rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-8">
|
||||
<p className="text-sm uppercase tracking-[0.24em] text-[var(--r-sub)] lg:hidden">Bible Study Project</p>
|
||||
<h1 className="mt-2 text-xl font-medium text-[var(--r-text)]">
|
||||
{authMode === 'login' ? 'Sign in' : 'Create an account'}
|
||||
</h1>
|
||||
<div className="mx-auto grid max-w-6xl gap-x-14 gap-y-12 px-5 pb-16 pt-8 sm:px-8 lg:grid-cols-[minmax(0,1fr)_24rem] lg:pt-14">
|
||||
{/* Sign in / create account. First on a phone; on a wide screen it stays in view beside the tour. */}
|
||||
<div className="lg:order-2">
|
||||
<div className="lg:sticky lg:top-10">
|
||||
<div className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-7 shadow-sm sm:p-8">
|
||||
<h2 className="text-xl font-medium">{authMode === 'login' ? 'Sign in' : 'Create an account'}</h2>
|
||||
<p className="mt-1 text-sm text-[var(--r-sub)]">
|
||||
{authMode === 'login' ? 'Pick up where you left off.' : 'Free to start. Your studies stay in your account.'}
|
||||
</p>
|
||||
<form onSubmit={submitAuth} className="mt-6 space-y-4">
|
||||
<label className="block text-sm">
|
||||
Email
|
||||
@@ -151,10 +219,10 @@ export default function AuthPage() {
|
||||
autoComplete="email"
|
||||
value={authForm.email}
|
||||
onChange={(e) => setAuthForm((f) => ({ ...f, email: 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)]"
|
||||
className="mt-1 block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2.5 text-sm text-[var(--r-text)]"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-[var(--r-text)]">
|
||||
<label className="block text-sm">
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
@@ -163,10 +231,11 @@ export default function AuthPage() {
|
||||
autoComplete={authMode === 'login' ? 'current-password' : 'new-password'}
|
||||
value={authForm.password}
|
||||
onChange={(e) => setAuthForm((f) => ({ ...f, password: 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)]"
|
||||
className="mt-1 block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2.5 text-sm text-[var(--r-text)]"
|
||||
/>
|
||||
{authMode === 'register' && <span className="mt-1 block text-xs text-[var(--r-sub)]">At least 8 characters.</span>}
|
||||
</label>
|
||||
{authError && <p className="text-sm text-[var(--r-danger,#b42318)]">{authError}</p>}
|
||||
{authError && <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{authError}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={authBusy}
|
||||
@@ -183,9 +252,47 @@ export default function AuthPage() {
|
||||
}}
|
||||
className="mt-4 w-full text-center text-sm text-[var(--r-sub)] underline hover:text-[var(--r-text)]"
|
||||
>
|
||||
{authMode === 'login' ? "Need an account? Sign up" : 'Already have an account? Sign in'}
|
||||
{authMode === 'login' ? 'Need an account? Sign up' : 'Already have an account? Sign in'}
|
||||
</button>
|
||||
</div>
|
||||
<blockquote className="mt-6 hidden border-l-2 border-[var(--r-line)] pl-4 text-sm italic leading-relaxed text-[var(--r-sub)] lg:block">
|
||||
“Make every effort to present yourself approved to God, an unashamed workman who accurately handles the word of truth.”
|
||||
<footer className="mt-1 not-italic">— 2 Timothy 2:15 (BSB)</footer>
|
||||
</blockquote>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* What the app does */}
|
||||
<div className="lg:order-1">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--r-accent)]">Bible study, start to finish</p>
|
||||
<h1 className="mt-3 text-4xl font-semibold leading-[1.1] tracking-tight sm:text-5xl">
|
||||
Read it. Study it.<br />Teach it.
|
||||
</h1>
|
||||
<p className="mt-5 max-w-xl text-base leading-relaxed text-[var(--r-sub)]">
|
||||
A place to read Scripture in six translations, work through a passage one chunk at a time, and turn what you learn into an outline,
|
||||
a script, and something you can share.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 grid gap-4 sm:grid-cols-2">
|
||||
<OutlinePreview />
|
||||
<ComparePreview />
|
||||
</div>
|
||||
|
||||
<div className="mt-12 grid gap-x-10 gap-y-9 sm:grid-cols-2">
|
||||
{FEATURES.map((group) => (
|
||||
<section key={group.title} aria-label={group.title}>
|
||||
<h2 className="border-b border-[var(--r-line)] pb-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--r-accent)]">{group.title}</h2>
|
||||
<ul className="mt-3 space-y-3.5">
|
||||
{group.points.map(([name, text]) => (
|
||||
<li key={name}>
|
||||
<p className="text-[15px] font-medium">{name}</p>
|
||||
<p className="mt-0.5 text-sm leading-relaxed text-[var(--r-sub)]">{text}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -106,6 +106,7 @@ export default function ReaderPage() {
|
||||
const [chromeVisible, setChromeVisible] = useState(true);
|
||||
const [readerDrawMode, setReaderDrawMode] = useState(false);
|
||||
const [selectionPicker, setSelectionPicker] = useState(null);
|
||||
const [xrefRequest, setXrefRequest] = useState(null); // a cross-reference tapped in the text: { verse, ref, n }
|
||||
const swipeTouchRef = useRef(null);
|
||||
const readerPageInkStrokes = readerInkByPage?.[`${readerBookAbbrev}_${readerChapter}`] ?? NO_STROKES;
|
||||
|
||||
@@ -298,6 +299,18 @@ export default function ReaderPage() {
|
||||
removeTextHighlight(id);
|
||||
if (gone) toast('Highlight removed', { action: { label: 'Undo', onClick: () => restoreTextHighlight(gone) } });
|
||||
};
|
||||
// Highlighting a whole verse from the verse panel: tapping the same colour again takes it off.
|
||||
const highlightVerse = (color) => {
|
||||
if (!selectedVerse) return;
|
||||
const existing = verseHighlights.find((h) => h.color === color && h.startOffset === 0 && h.endOffset >= selectedVerse.text.length);
|
||||
if (existing) { removeHighlight(existing.id); return; }
|
||||
addTextHighlight({ book: readerBookAbbrev, chapter: readerChapter, verse: selectedVerse.number, startOffset: 0, endOffset: selectedVerse.text.length, color, text: selectedVerse.text });
|
||||
};
|
||||
const clearVerseHighlights = () => {
|
||||
const gone = verseHighlights;
|
||||
gone.forEach((h) => removeTextHighlight(h.id));
|
||||
if (gone.length) toast(`Removed ${gone.length} highlight${gone.length === 1 ? '' : 's'}`, { action: { label: 'Undo', onClick: () => gone.forEach((h) => restoreTextHighlight(h)) } });
|
||||
};
|
||||
const removeBookmark = (key, color) => {
|
||||
toggleReaderBookmark(key);
|
||||
toast('Bookmark removed', { action: { label: 'Undo', onClick: () => restoreReaderBookmark?.(key, color) } });
|
||||
@@ -387,6 +400,7 @@ export default function ReaderPage() {
|
||||
}).sort((a, b) => a.bookIndex - b.bookIndex || a.chapter - b.chapter || a.verse - b.verse || a.startOffset - b.startOffset);
|
||||
|
||||
const selectedVerse = readerSelectedVerse != null ? readerVerses.find((v) => v.number === readerSelectedVerse) : null;
|
||||
const verseHighlights = selectedVerse ? (readerTextHighlights || []).filter((h) => h.book === readerBookAbbrev && h.chapter === readerChapter && h.verse === selectedVerse.number) : [];
|
||||
const selectedKey = selectedVerse ? `${readerBookAbbrev}-${readerChapter}-${selectedVerse.number}` : null;
|
||||
const title = `${readerBook?.name ?? ''} ${readerChapter} · ${readerTranslation}`;
|
||||
const audio = {
|
||||
@@ -459,6 +473,7 @@ export default function ReaderPage() {
|
||||
searchLower={chapterSearch}
|
||||
crossRefs={readerCrossRefs}
|
||||
showCrossRefs={readerShowCrossRefs}
|
||||
onOpenCrossRef={(verse, ref) => { setReaderSelectedVerse(verse); setXrefRequest((r) => ({ verse, ref, n: (r?.n ?? 0) + 1 })); }}
|
||||
onSelectionEnd={handleSelectionEnd}
|
||||
footer={(
|
||||
<PlanFooter
|
||||
@@ -528,6 +543,9 @@ export default function ReaderPage() {
|
||||
onCycleColor={() => cycleBookmarkColor(selectedKey)}
|
||||
onJump={jump}
|
||||
onClose={() => setReaderSelectedVerse(null)}
|
||||
request={xrefRequest}
|
||||
verseHighlights={verseHighlights} colorLabels={readerColorLabels ?? {}}
|
||||
onHighlightVerse={highlightVerse} onClearVerseHighlights={clearVerseHighlights}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { render, screen, fireEvent, within, waitFor } from '@testing-library/react';
|
||||
import { StrictMode } from 'react';
|
||||
import { vi, test, expect } from 'vitest';
|
||||
|
||||
const noop = () => {};
|
||||
@@ -58,6 +59,64 @@ test('the compare button next to Aa opens the comparison sheet, and choosing a B
|
||||
expect(ctx.setReaderParallel).toHaveBeenCalledWith('NET');
|
||||
});
|
||||
|
||||
test('a cross-reference under a verse opens the side panel with that passage, without leaving the chapter', async () => {
|
||||
const saved = { ...ctx };
|
||||
Object.assign(ctx, {
|
||||
readerShowCrossRefs: true, readerSelectedVerse: 1,
|
||||
readerCrossRefs: { 1: [{ book: 'ROM', chapter: 5, verse: 8, endVerse: 9 }] },
|
||||
jumpToReaderVerse: vi.fn(),
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn((url) => Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(String(url).includes('/api/bible/')
|
||||
? { verses: [{ number: 7, text: 'Seven.' }, { number: 8, text: 'But God shows His love for us.' }, { number: 9, text: 'Much more shall we be saved.' }, { number: 10, text: 'Ten.' }] }
|
||||
: { chapter: { content: [] } }),
|
||||
})));
|
||||
render(<StrictMode><ReaderPage /></StrictMode>); // dev builds run effects twice; the preview must survive that
|
||||
const chip = within(screen.getByRole('article')).getByRole('button', { name: 'Romans 5:8-9' });
|
||||
fireEvent.click(chip);
|
||||
const card = await screen.findByRole('region', { name: /cross-reference romans 5:8-9/i });
|
||||
await waitFor(() => expect(within(card).getByText(/But God shows His love/)).toBeTruthy());
|
||||
expect(within(card).getByText(/Much more shall we be saved/)).toBeTruthy();
|
||||
expect(within(card).queryByText('Seven.')).toBeNull(); // only the verses in the reference
|
||||
expect(ctx.setReaderSelectedVerse).not.toHaveBeenCalledWith(null); // the click didn't toggle the verse off
|
||||
fireEvent.click(within(card).getByRole('button', { name: /open chapter/i }));
|
||||
expect(ctx.jumpToReaderVerse).toHaveBeenCalledWith('ROM', 5, 8); // only now does it leave the chapter
|
||||
fireEvent.click(within(card).getByRole('button', { name: /close cross-reference/i }));
|
||||
expect(screen.queryByRole('region', { name: /cross-reference romans/i })).toBeNull();
|
||||
Object.assign(ctx, saved);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test('the verse panel has one-tap highlight colours for the whole verse', () => {
|
||||
const saved = { ...ctx };
|
||||
Object.assign(ctx, { readerSelectedVerse: 2, addTextHighlight: vi.fn(), removeTextHighlight: vi.fn(), restoreTextHighlight: vi.fn(), readerTextHighlights: [] });
|
||||
render(<ReaderPage />);
|
||||
const row = screen.getByRole('group', { name: /highlight this verse/i });
|
||||
fireEvent.click(within(row).getByRole('button', { name: /highlight verse green/i }));
|
||||
expect(ctx.addTextHighlight).toHaveBeenCalledWith(expect.objectContaining({
|
||||
book: 'JHN', chapter: 3, verse: 2, startOffset: 0, endOffset: 'This man came to Jesus by night.'.length, color: '#bbf7d0', text: 'This man came to Jesus by night.',
|
||||
}));
|
||||
Object.assign(ctx, saved);
|
||||
});
|
||||
|
||||
test('tapping a verse colour again takes it off, and Clear removes them all (with Undo)', () => {
|
||||
const saved = { ...ctx };
|
||||
const text = 'This man came to Jesus by night.';
|
||||
const hl = (id, color, start = 0, end = text.length) => ({ id, book: 'JHN', chapter: 3, verse: 2, startOffset: start, endOffset: end, color });
|
||||
Object.assign(ctx, { readerSelectedVerse: 2, addTextHighlight: vi.fn(), removeTextHighlight: vi.fn(), restoreTextHighlight: vi.fn(), readerTextHighlights: [hl('a', '#bbf7d0'), hl('b', '#fef08a', 5, 8)] });
|
||||
render(<ReaderPage />);
|
||||
const row = screen.getByRole('group', { name: /highlight this verse/i });
|
||||
expect(within(row).getByRole('button', { name: /highlight verse green/i })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(within(row).getByRole('button', { name: /highlight verse yellow/i })).toHaveAttribute('aria-pressed', 'false'); // a part-verse highlight isn't "the verse"
|
||||
fireEvent.click(within(row).getByRole('button', { name: /highlight verse green/i }));
|
||||
expect(ctx.removeTextHighlight).toHaveBeenCalledWith('a');
|
||||
expect(ctx.addTextHighlight).not.toHaveBeenCalled();
|
||||
fireEvent.click(within(row).getByRole('button', { name: /^clear$/i }));
|
||||
expect(ctx.removeTextHighlight).toHaveBeenCalledWith('b');
|
||||
Object.assign(ctx, saved);
|
||||
});
|
||||
|
||||
test('tapping a verse selects it', () => {
|
||||
render(<ReaderPage />);
|
||||
fireEvent.click(screen.getByText(/This man came to Jesus/));
|
||||
|
||||
Reference in new issue
Block a user