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
+104
-35
@@ -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,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user