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

+104 -35
View File
@@ -6,21 +6,29 @@ import { bookOptions, CHAPTER_COUNTS, parseBibleChapter } from '../utils/bibleUt
import { useReaderMarksSync } from './useReaderMarksSync.js';
import { usePrefsSync } from './usePrefsSync.js';
import { COMMENTARIES } from './useVerseStudy.js';
import { READER_TRANSLATIONS, fetchChapter } from '../utils/bibleText.js';
import { mergeMarks, normalizeMarks } from '../utils/readerMarks.js';
import { BIBLE_IDB_TTL, getBibleIndexFromIDB, saveBibleIndexToIDB } from '../utils/bibleIndexCache.js';
export const READER_TRANSLATIONS = [
{ id: 'BSB', name: 'Berean Standard Bible' },
{ id: 'ESV', name: 'English Standard Version', licensed: true,
notice: 'Scripture quotations are from the ESV® Bible (The Holy Bible, English Standard Version®), © 2001 by Crossway, a publishing ministry of Good News Publishers. Used by permission. All rights reserved.' },
{ id: 'NET', name: 'NET Bible', helloao: 'eng_net',
notice: 'Scripture quoted by permission. Quotations designated (NET) are from the NET Bible® copyright ©1996-2017 by Biblical Studies Press, L.L.C. http://netbible.com All rights reserved.' },
{ id: 'NLT', name: 'New Living Translation', licensed: true,
notice: 'Holy Bible, New Living Translation, Copyright © 2014, Tyndale House Publishers. All rights reserved. tyndale.com' },
{ id: 'CSB', name: 'Christian Standard Bible', licensed: true,
notice: 'Christian Standard Bible® and CSB® are federally registered trademarks of Holman Bible Publishers. All rights reserved. bhpublishinggroup.com' },
{ id: 'NASB', name: 'New American Standard Bible', licensed: true,
notice: 'New American Standard Bible®, Copyright © 1995, The Lockman Foundation. All rights reserved. lockman.org' },
];
export { READER_TRANSLATIONS };
// A per-device choice kept in localStorage: useStoredChoice('reader-font', 'serif', ['serif', 'sans']).
function useStoredChoice(key, fallback, allowed) {
const [value, setValue] = useState(() => {
try {
const saved = localStorage.getItem(key);
return saved !== null && allowed.includes(saved) ? saved : fallback;
} catch { return fallback; }
});
const set = (next) => {
setValue(next);
try { localStorage.setItem(key, next); } catch {}
};
return [value, set];
}
export const READER_FONTS = ['serif', 'sans', 'easy'];
export const READER_SPACINGS = ['snug', 'normal', 'airy'];
const BOOKMARK_COLORS = ['#fef08a', '#bbf7d0', '#bfdbfe', '#fecaca', '#e9d5ff'];
@@ -91,6 +99,11 @@ export function useBibleReader(currentPage, translationOverride = null) {
setReaderWideState(!!on);
try { localStorage.setItem('reader-wide', on ? '1' : '0'); } catch {}
};
// Typography and a second Bible shown beside the first — all per-device, like text size.
const [readerFont, setReaderFont] = useStoredChoice('reader-font', 'serif', READER_FONTS);
const [readerSpacing, setReaderSpacing] = useStoredChoice('reader-spacing', 'normal', READER_SPACINGS);
const [readerVerseNumbers, setReaderVerseNumbersRaw] = useStoredChoice('reader-verse-numbers', 'on', ['on', 'off']);
const [readerParallel, setReaderParallel] = useStoredChoice('reader-parallel', '', ['', ...READER_TRANSLATIONS.map((t) => t.id)]);
// The page the app opens to, and where every Home button goes: 'studies' | 'reader' | 'plans'
const [startPagePref, setStartPagePrefState] = useState(() => {
try {
@@ -136,6 +149,21 @@ export function useBibleReader(currentPage, translationOverride = null) {
return Array.isArray(val) ? val : [];
} catch { return []; }
});
// What each highlight colour means to this reader ({ '#fef08a': 'Promises' }); follows the account.
const [readerColorLabels, setReaderColorLabelsState] = useState(() => {
try {
const val = JSON.parse(localStorage.getItem('reader-color-labels') || '{}');
return val && typeof val === 'object' && !Array.isArray(val) ? val : {};
} catch { return {}; }
});
const setReaderColorLabel = (color, label) => {
setReaderColorLabelsState((prev) => {
const next = { ...prev };
if (label.trim()) next[color] = label.slice(0, 30); else delete next[color];
try { localStorage.setItem('reader-color-labels', JSON.stringify(next)); } catch {}
return next;
});
};
// Debounce timer ref so rapid stroke updates don't flood the server
const inkSaveTimerRef = useRef({});
@@ -210,38 +238,25 @@ export function useBibleReader(currentPage, translationOverride = null) {
// BSB comes straight from HelloAO; licensed translations (NLT/CSB/NASB) go
// through the server's api.bible proxy so the API key never reaches the browser.
// The most recent request wins: flipping chapters quickly (or changing translation) must never let a
// slow earlier response replace the page you are now on.
const chapterRequestRef = useRef(0);
const loadReaderChapter = async (bookAbbrev, chapterNumber, translation = readerTranslation) => {
const requestId = ++chapterRequestRef.current;
setReaderLoading(true);
setReaderError('');
try {
let verses;
let copyright = '';
let totalChapters = CHAPTER_COUNTS[bookAbbrev] ?? chapterNumber;
const free = READER_TRANSLATIONS.find((t) => t.id === translation && !t.licensed);
if (free) {
const res = await fetch(`https://bible.helloao.org/api/${free.helloao ?? free.id}/${bookAbbrev}/${chapterNumber}.json`);
if (!res.ok) throw new Error('Unable to load chapter.');
const data = await res.json();
verses = parseBibleChapter(data);
totalChapters = data.book?.numberOfChapters ?? totalChapters;
} else {
const res = await fetch(`/api/bible/${translation}/${bookAbbrev}/${chapterNumber}`, { credentials: 'include' });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Unable to load ${translation}.`);
verses = data.verses;
copyright = data.copyright ?? '';
}
if (!Array.isArray(verses) || verses.length === 0) {
throw new Error('Invalid Bible data returned.');
}
const { verses, copyright, totalChapters } = await fetchChapter(translation, bookAbbrev, chapterNumber);
if (requestId !== chapterRequestRef.current) return;
setReaderVerses(verses);
setReaderCopyright(copyright);
setReaderTotalChapters(totalChapters);
} catch (err) {
if (requestId !== chapterRequestRef.current) return;
setReaderError(err.message || 'Failed to load chapter.');
setReaderVerses([]);
} finally {
setReaderLoading(false);
if (requestId === chapterRequestRef.current) setReaderLoading(false);
}
};
@@ -256,7 +271,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
if (readerJumpVerse == null || readerLoading) return;
const el = document.getElementById(`reader-verse-${readerJumpVerse}`);
if (!el) return;
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.scrollIntoView({ behavior: window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth', block: 'center' });
const prevTransition = el.style.transition;
const prevBackground = el.style.backgroundColor;
el.style.transition = 'background-color 0.4s';
@@ -347,6 +362,16 @@ export function useBibleReader(currentPage, translationOverride = null) {
});
};
// Puts a removed bookmark back with its colour (used by "Undo").
const restoreReaderBookmark = (verseKey, color) => {
setReaderBookmarks((prev) => {
if (prev[verseKey]) return prev;
const next = { ...prev, [verseKey]: color };
try { localStorage.setItem('reader-bookmarks', JSON.stringify(next)); } catch {}
return next;
});
};
const updateReaderPageInk = useCallback((bookAbbrev, chapter, strokes) => {
const key = `${bookAbbrev}_${chapter}`;
setReaderInkByPage((prev) => {
@@ -385,6 +410,16 @@ export function useBibleReader(currentPage, translationOverride = null) {
});
};
// Puts a removed highlight back exactly as it was (used by "Undo").
const restoreTextHighlight = (highlight) => {
setReaderTextHighlights((prev) => {
if (prev.some((h) => h.id === highlight.id)) return prev;
const next = [...prev, highlight];
try { localStorage.setItem('readerTextHighlights', JSON.stringify(next)); } catch {}
return next;
});
};
const removeTextHighlight = (id) => {
setReaderTextHighlights(prev => {
const next = prev.filter(h => h.id !== id);
@@ -417,6 +452,30 @@ export function useBibleReader(currentPage, translationOverride = null) {
}
};
// Bookmarks, highlights, colour names and ink from a backup file are added to what is here (nothing is
// removed, and where both have something the copy already here wins).
const importReaderMarks = ({ bookmarks, highlights, colorLabels, ink }) => {
const merged = mergeMarks(normalizeMarks({ bookmarks, highlights }), normalizeMarks({ bookmarks: readerBookmarks, highlights: readerTextHighlights }));
setReaderBookmarks(merged.bookmarks);
setReaderTextHighlights(merged.highlights);
try {
localStorage.setItem('reader-bookmarks', JSON.stringify(merged.bookmarks));
localStorage.setItem('readerTextHighlights', JSON.stringify(merged.highlights));
} catch {}
if (colorLabels && Object.keys(colorLabels).length > 0) {
setReaderColorLabelsState((prev) => {
const next = { ...colorLabels, ...prev };
try { localStorage.setItem('reader-color-labels', JSON.stringify(next)); } catch {}
return next;
});
}
// Ink goes through the normal save so it also reaches the account
for (const [key, strokes] of Object.entries(ink ?? {})) {
const [book, chapter] = key.split('_');
if (!readerInkByPage[key] && book && chapter && Array.isArray(strokes)) updateReaderPageInk(book, chapter, strokes);
}
};
// Merges reader ink loaded from the server at sign-in; server wins over local.
const mergeServerInk = useCallback((serverInk) => {
setReaderInkByPage((local) => {
@@ -438,6 +497,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
theme: readerTheme, translation: baseTranslation, layout: readerLayout, commentarySource: readerCommentary,
planTranslation: planTranslationPref, startPage: startPagePref,
position: { book: readerBookAbbrev, chapter: readerChapter },
colorLabels: readerColorLabels,
},
apply: (key, v) => {
if (key === 'theme') { setReaderThemeState(v); try { localStorage.setItem('reader-theme', v); } catch {} }
@@ -446,6 +506,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
else if (key === 'startPage') { setStartPagePrefState(v); try { localStorage.setItem('start-page', v); } catch {} }
else if (key === 'planTranslation') { setPlanTranslationPrefState(v); try { localStorage.setItem('plan-translation', v); } catch {} }
else if (key === 'commentarySource') { setReaderCommentaryState(v); try { localStorage.setItem('reader-commentary', v); } catch {} }
else if (key === 'colorLabels') { setReaderColorLabelsState(v); try { localStorage.setItem('reader-color-labels', JSON.stringify(v)); } catch {} }
else if (key === 'position') { setReaderBookAbbrev(v.book); setReaderChapter(v.chapter); setReaderSelectedVerse(null); }
},
canApplyPosition: () => pageRef.current !== 'reader',
@@ -467,6 +528,10 @@ export function useBibleReader(currentPage, translationOverride = null) {
readerSelectedVerse, setReaderSelectedVerse,
readerFontSize, setReaderFontSize,
readerWide, setReaderWide,
readerFont, setReaderFont,
readerSpacing, setReaderSpacing,
readerVerseNumbers: readerVerseNumbers === 'on', setReaderVerseNumbers: (on) => setReaderVerseNumbersRaw(on ? 'on' : 'off'),
readerParallel, setReaderParallel,
planTranslationPref, setPlanTranslationPref,
startPagePref, setStartPagePref,
readerBookmarks, setReaderBookmarks,
@@ -480,11 +545,14 @@ export function useBibleReader(currentPage, translationOverride = null) {
readerInkByPage,
updateReaderPageInk,
mergeServerInk,
importReaderMarks,
readerTextHighlights,
...marksSync,
...prefsSync,
addTextHighlight,
removeTextHighlight,
restoreTextHighlight,
readerColorLabels, setReaderColorLabel,
bibleIndexStatusFor,
loadBibleIndex,
_bibleIndexCacheRef,
@@ -494,6 +562,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
readerGoToNextChapter,
jumpToReaderVerse,
toggleReaderBookmark,
restoreReaderBookmark,
cycleBookmarkColor,
loadReaderCrossRefs,
};