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:
1 parent
1edbd364ea
commit
d13f55faf7
65 files changed
+2947
-208
No files matched your search
+88
-11
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user