Add Outline stage, parallel reading, offline saves, backup, sync-conflict handling and more

Study
- New Outline stage (hierarchical I./A./1. points, linked to chunks, in all exports);
  the chunk stage is now "Chunks"
- Home cards: passages shown as ranges, tags capped with "+N more"

Reader
- Side-by-side translations and a Compare tab per verse
- Typeface, line spacing, verse-number settings; text and column scale with the screen
- Highlights/bookmarks sheet with named colours (synced); BSB footnote spacing fix
- Cmd-K palette (passages, studies, notes, marks), keyboard shortcuts and "?" help

Flow and safety
- Toasts with Undo replace most confirm()/alert()
- Back/Forward move between pages
- Server refuses saves over a newer copy (409); keep-mine / use-other banner;
  opening a study no longer re-stamps it
- Latest chapter request wins when flipping quickly

Offline and data
- Save books, plan days or a whole BSB/NET for offline
- Service worker fixes: helloao /api/ URLs were skipped and responses cloned too late
- Settings: download/restore a full backup

Accessibility
- Sheets trap focus and close on Escape, skip link, focus ring, reduced motion,
  theme contrast raised to 4.5:1 (with a test)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-01 14:08:08 -04:00
1 parent 1edbd364ea
commit d13f55faf7
65 files changed
+2947 -208

No files matched your search

+88 -11
View File
@@ -5,7 +5,9 @@ import { useApp } from '../context/AppContext.js';
import { bookOptions } from '../utils/bibleUtils.jsx';
import { getPlan, getPlanSchedule, findDayIndexForRef, formatDayReadings, nextUnreadDayIndex, computeStreak, nearestReadingDay } from '../utils/readingPlans.js';
import DrawCanvas from './DrawCanvas.jsx';
import { READER_TRANSLATIONS } from '../hooks/useBibleReader.js';
import { READER_TRANSLATIONS } from '../utils/bibleText.js';
import { useParallelChapter } from '../hooks/useParallelChapter.js';
import { useOfflineSave, useOnline } from '../hooks/useOfflineSave.js';
import { useBibleSearch } from '../hooks/useBibleSearch.js';
import { themeVars } from '../components/reader/readerThemes.js';
import { TopBar, BottomBar } from '../components/reader/ReaderBars.jsx';
@@ -14,7 +16,9 @@ import PlanStrip from '../components/reader/PlanStrip.jsx';
import NavigatorSheet from '../components/reader/NavigatorSheet.jsx';
import SettingsSheet from '../components/reader/SettingsSheet.jsx';
import SearchSheet from '../components/reader/SearchSheet.jsx';
import BookmarksSheet from '../components/reader/BookmarksSheet.jsx';
import MarksSheet from '../components/reader/MarksSheet.jsx';
import { useToast } from '../components/Toasts.jsx';
import { HIGHLIGHT_COLORS, colorTitle } from '../utils/highlightColors.js';
import AudioSheet from '../components/reader/AudioSheet.jsx';
import VerseSheet from '../components/reader/VerseSheet.jsx';
import PlanSheet from '../components/reader/PlanSheet.jsx';
@@ -22,7 +26,6 @@ import PlanFooter from '../components/reader/PlanFooter.jsx';
import { FocusIcon } from '../components/reader/icons.jsx';
const NO_STROKES = []; // stable fallback so DrawCanvas doesn't redraw every render
const HIGHLIGHT_COLORS = ['#fef08a', '#bbf7d0', '#bfdbfe', '#fecaca', '#e9d5ff', '#fed7aa'];
export default function ReaderPage() {
const {
@@ -40,6 +43,10 @@ export default function ReaderPage() {
readerSelectedVerse, setReaderSelectedVerse,
readerFontSize, setReaderFontSize,
readerWide: readerWideLayout, setReaderWide: setReaderWideLayout,
readerFont, setReaderFont,
readerSpacing, setReaderSpacing,
readerVerseNumbers, setReaderVerseNumbers,
readerParallel, setReaderParallel,
readerBookmarks,
readerCrossRefs, setReaderCrossRefs,
readerCrossRefsLoading,
@@ -56,6 +63,7 @@ export default function ReaderPage() {
readerGoToNextChapter,
jumpToReaderVerse,
toggleReaderBookmark,
restoreReaderBookmark,
cycleBookmarkColor,
loadReaderCrossRefs,
loadBibleIndex,
@@ -69,6 +77,8 @@ export default function ReaderPage() {
readerTextHighlights,
addTextHighlight,
removeTextHighlight,
restoreTextHighlight,
readerColorLabels, setReaderColorLabel,
planProgress,
toggleDay,
toggleChapterRead,
@@ -77,10 +87,18 @@ export default function ReaderPage() {
openPlanReading,
studyReadingPlanDay,
studyBuildBusy,
readerRequest, clearReaderRequest,
} = useApp();
const { toast } = useToast();
const offline = useOfflineSave();
const online = useOnline();
const readerBook = bookOptions.find((b) => b.abbrev === readerBookAbbrev);
const readerNotice = READER_TRANSLATIONS.find((t) => t.id === readerTranslation)?.notice ?? '';
// A second Bible beside the first (never the same one twice, and not while drawing)
const parallelId = readerParallel && readerParallel !== readerTranslation ? readerParallel : '';
const parallelChapter = useParallelChapter(parallelId, readerBookAbbrev, readerChapter);
const parallelSource = READER_TRANSLATIONS.find((t) => t.id === parallelId);
const [sheet, setSheet] = useState(null); // null | 'nav' | 'settings' | 'search' | 'bookmarks' | 'audio'
const [chromeVisible, setChromeVisible] = useState(true);
@@ -203,6 +221,17 @@ export default function ReaderPage() {
}, [bibleIndex, bibleIndexLower, deferredBibleQuery]);
const remoteSearch = useBibleSearch(readerTranslation, readerSearch, wholeBibleSearching && !localSearch);
// "Search the Bible for …" from the ⌘K palette: open the whole-Bible search with the text filled in.
useEffect(() => {
if (!readerRequest?.search) return;
setReaderSearchScope('bible');
setReaderSearch(readerRequest.search);
setReaderSearchActive(true);
setSheet('search');
if (localSearch && indexStatus === 'idle') loadBibleIndex(readerTranslation);
clearReaderRequest();
}, [readerRequest]); // eslint-disable-line react-hooks/exhaustive-deps
const openSheet = (name) => {
setSheet(name);
if (name === 'search') setReaderSearchActive(true);
@@ -233,9 +262,20 @@ export default function ReaderPage() {
useEffect(() => {
const handleKey = (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (e.key === 'ArrowLeft') readerGoToPreviousChapter();
else if (e.key === 'ArrowRight') readerGoToNextChapter();
else if (e.key === 'Escape') { if (sheet) closeSheet(); else if (readerDrawMode) setReaderDrawMode(false); else setReaderSelectedVerse(null); }
else if (!sheet && !readerDrawMode) {
// One-key shortcuts (the "?" list shows them)
if (e.key === '/') { e.preventDefault(); openSheet('search'); }
else if (e.key === 'g' || e.key === 'G') { e.preventDefault(); openSheet('nav'); }
else if (e.key === ',') { e.preventDefault(); openSheet('settings'); }
else if (e.key === 'f' || e.key === 'F') setChromeVisible((v) => !v);
else if ((e.key === 'b' || e.key === 'B') && selectedKey) {
readerBookmarks[selectedKey] ? removeBookmark(selectedKey, readerBookmarks[selectedKey]) : toggleReaderBookmark(selectedKey);
}
}
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
@@ -249,6 +289,17 @@ export default function ReaderPage() {
return () => document.removeEventListener('pointerdown', dismiss);
}, [selectionPicker]);
// Removing a highlight or bookmark is one tap, so it can always be taken back.
const removeHighlight = (id) => {
const gone = (readerTextHighlights || []).find((h) => h.id === id);
removeTextHighlight(id);
if (gone) toast('Highlight removed', { action: { label: 'Undo', onClick: () => restoreTextHighlight(gone) } });
};
const removeBookmark = (key, color) => {
toggleReaderBookmark(key);
toast('Bookmark removed', { action: { label: 'Undo', onClick: () => restoreReaderBookmark?.(key, color) } });
};
// Render verse text with colored highlights and optional search match highlight
const chapterSearch = sheet === 'search' && readerSearchScope === 'chapter' ? readerSearch.trim().toLowerCase() : '';
function renderVerseText(verseNum, text, searchLow) {
@@ -276,7 +327,8 @@ export default function ReaderPage() {
if (s.type === 'hl') {
parts.push(
<mark key={s.id} style={{ backgroundColor: s.color, color: '#2b2620', borderRadius: '2px', cursor: 'pointer' }}
onClick={(e) => { e.stopPropagation(); removeTextHighlight(s.id); }} title="Tap to remove highlight">
onClick={(e) => { e.stopPropagation(); removeHighlight(s.id); }}
title={`${colorTitle(s.color, readerColorLabels ?? {})} · tap to remove`}>
{text.slice(s.start, s.end)}
</mark>,
);
@@ -307,7 +359,7 @@ export default function ReaderPage() {
const selectedText = sel.toString();
const startOffset = verse.text.indexOf(selectedText);
if (startOffset === -1) return;
setSelectionPicker({ x: (rect.left + rect.right) / 2, y: rect.top, verse: verseNum, startOffset, endOffset: startOffset + selectedText.length });
setSelectionPicker({ x: (rect.left + rect.right) / 2, y: rect.top, verse: verseNum, startOffset, endOffset: startOffset + selectedText.length, text: selectedText });
}, 20);
}
@@ -317,6 +369,12 @@ export default function ReaderPage() {
return { key, color, bookAbbrev: bAbbrev, bookName: bookOptions[bookIndex]?.name ?? bAbbrev, chapter: Number(chapterStr), verse: Number(verseStr), bookIndex };
}).sort((a, b) => a.bookIndex - b.bookIndex || a.chapter - b.chapter || a.verse - b.verse);
// Highlights newest-last in reading order, with the book's name for the list
const highlightEntries = (readerTextHighlights || []).map((h) => {
const bookIndex = bookOptions.findIndex((b) => b.abbrev === h.book);
return { ...h, bookName: bookOptions[bookIndex]?.name ?? h.book, bookIndex };
}).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 selectedKey = selectedVerse ? `${readerBookAbbrev}-${readerChapter}-${selectedVerse.number}` : null;
const title = `${readerBook?.name ?? ''} ${readerChapter} · ${readerTranslation}`;
@@ -337,7 +395,7 @@ export default function ReaderPage() {
onOpenNavigator={() => openSheet('nav')}
onSearch={() => openSheet('search')}
onBookmarks={() => openSheet('bookmarks')}
bookmarkCount={bookmarkEntries.length}
bookmarkCount={bookmarkEntries.length + highlightEntries.length}
onSettings={() => openSheet('settings')}
onDoneDrawing={readerDrawMode ? () => setReaderDrawMode(false) : undefined}
/>
@@ -359,11 +417,20 @@ export default function ReaderPage() {
/>
)}
{!online && (
<p role="status" className="border-b border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-1.5 text-center font-sans text-xs text-[var(--r-sub)]">
You’re offline. Chapters you’ve read or saved still open; save a whole book from “Go to” while you’re connected.
</p>
)}
<div className={paneOpen ? 'lg:pr-[24rem]' : ''}>
{!readerDrawMode && (
<ReaderText
title={`${readerBook?.name ?? ''} ${readerChapter}`}
translation={READER_TRANSLATIONS.find((t) => t.id === readerTranslation)?.name ?? readerTranslation}
translationId={readerTranslation}
font={readerFont} spacing={readerSpacing} showVerseNumbers={readerVerseNumbers}
parallel={parallelId ? { translation: parallelId, name: parallelSource?.name ?? parallelId, notice: parallelSource?.notice ?? '', ...parallelChapter } : null}
notice={readerNotice}
verses={readerVerses}
loading={readerLoading}
@@ -445,7 +512,7 @@ export default function ReaderPage() {
crossRefsLoading={readerCrossRefsLoading}
onLoadCrossRefs={() => { if (!readerCrossRefs) loadReaderCrossRefs(readerBookAbbrev, readerChapter); }}
bookmarkColor={readerBookmarks[selectedKey]}
onToggleBookmark={() => toggleReaderBookmark(selectedKey)}
onToggleBookmark={() => (readerBookmarks[selectedKey] ? removeBookmark(selectedKey, readerBookmarks[selectedKey]) : toggleReaderBookmark(selectedKey))}
onCycleColor={() => cycleBookmarkColor(selectedKey)}
onJump={jump}
onClose={() => setReaderSelectedVerse(null)}
@@ -471,7 +538,7 @@ export default function ReaderPage() {
bookAbbrev={readerBookAbbrev} chapter={readerChapter}
translation={readerTranslation} onTranslation={setReaderTranslation}
translationNote={ctxInfo ? `Reading “${ctxInfo.plan.title}”: this sets the plan’s translation.` : ''}
onGo={goTo}
onGo={goTo} offline={offline}
/>
<SettingsSheet
open={sheet === 'settings'} onClose={closeSheet}
@@ -479,6 +546,10 @@ export default function ReaderPage() {
theme={readerTheme} onTheme={setReaderTheme}
layout={readerLayout} onLayout={setReaderLayout}
wide={readerWideLayout} onWide={setReaderWideLayout}
font={readerFont} onFont={setReaderFont}
spacing={readerSpacing} onSpacing={setReaderSpacing}
verseNumbers={readerVerseNumbers} onVerseNumbers={setReaderVerseNumbers}
translation={readerTranslation} parallel={parallelId} onParallel={setReaderParallel}
/>
<SearchSheet
open={sheet === 'search'} onClose={closeSheet}
@@ -490,9 +561,13 @@ export default function ReaderPage() {
bibleMatches={bibleMatches} deferredBibleQuery={deferredBibleQuery} remote={remoteSearch}
onJump={jump}
/>
<BookmarksSheet
<MarksSheet
open={sheet === 'bookmarks'} onClose={closeSheet}
entries={bookmarkEntries} onJump={jump} onRemove={toggleReaderBookmark}
bookmarks={bookmarkEntries} highlights={highlightEntries}
colorLabels={readerColorLabels ?? {}} onSetLabel={setReaderColorLabel}
onJump={jump}
onRemoveBookmark={(e) => removeBookmark(e.key, e.color)}
onRemoveHighlight={(h) => removeHighlight(h.id)}
/>
<AudioSheet
open={sheet === 'audio'} onClose={closeSheet}
@@ -514,7 +589,8 @@ export default function ReaderPage() {
{HIGHLIGHT_COLORS.map((color) => (
<button
key={color}
aria-label="Highlight"
aria-label={`Highlight ${colorTitle(color, readerColorLabels ?? {}).toLowerCase()}`}
title={colorTitle(color, readerColorLabels ?? {})}
className="h-7 w-7 rounded-full border-2 border-white shadow-sm transition-transform hover:scale-110 active:scale-95"
style={{ backgroundColor: color }}
onPointerDown={(e) => {
@@ -522,6 +598,7 @@ export default function ReaderPage() {
addTextHighlight({
book: readerBookAbbrev, chapter: readerChapter, verse: selectionPicker.verse,
startOffset: selectionPicker.startOffset, endOffset: selectionPicker.endOffset, color,
text: selectionPicker.text,
});
window.getSelection()?.removeAllRanges();
setSelectionPicker(null);