Speed up the app, run production build, remove chunk ink, start App.jsx split
Performance: - Lazy-load pages, docx, and mammoth (initial JS 1,298 KB -> ~290 KB) - Gzip responses and cache hashed assets for a year - Memoize DrawCanvas strokes so ink isn't redrawn on every render - Precompute whole-Bible search index and defer the query while typing - Reload once if a lazy chunk is missing after a deploy Deployment: - setup.sh builds and runs one production server on APP_PORT (default 5173); --dev keeps the old Vite dev setup - Session cookie uses secure: 'auto' so login works behind any proxy Chunk editor: - Remove the Draw layout, Ink Notes panel, and ink in HTML export (existing inkStrokes data is left in saved projects) Refactor: - Move module-level helpers out of App.jsx into src/utils/* - Extract Bible reader state and logic into hooks/useBibleReader Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
00932dee4a
commit
ade7f1af6f
20 files changed
+1314
-1252
No files matched your search
@@ -0,0 +1,330 @@
|
||||
// State, data loading, and helpers for the read-only Bible reader page.
|
||||
// Audio playback stays in App because the study page's book player shares
|
||||
// the same <audio> element.
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { bookOptions, parseBibleChapter } from '../utils/bibleUtils.jsx';
|
||||
import { BIBLE_IDB_TTL, getBibleIndexFromIDB, saveBibleIndexToIDB } from '../utils/bibleIndexCache.js';
|
||||
|
||||
const BOOKMARK_COLORS = ['#fef08a', '#bbf7d0', '#bfdbfe', '#fecaca', '#e9d5ff'];
|
||||
|
||||
export function useBibleReader(currentPage) {
|
||||
const [readerBookAbbrev, setReaderBookAbbrev] = useState(bookOptions[0].abbrev);
|
||||
const [readerChapter, setReaderChapter] = useState(1);
|
||||
const [readerVerses, setReaderVerses] = useState([]);
|
||||
const [readerTotalChapters, setReaderTotalChapters] = useState(1);
|
||||
const [readerLoading, setReaderLoading] = useState(false);
|
||||
const [readerError, setReaderError] = useState('');
|
||||
const [readerInterlinear, setReaderInterlinear] = useState(null); // full book interlinear
|
||||
const [readerInterlinearLoading, setReaderInterlinearLoading] = useState(false);
|
||||
const [readerSelectedVerse, setReaderSelectedVerse] = useState(null); // verse number with open interlinear panel
|
||||
const _readerInterlinearCacheRef = useRef({});
|
||||
const [readerFontSize, setReaderFontSize] = useState(1); // em multiplier: 0.875 | 1 | 1.125 | 1.25
|
||||
const [readerBookmarks, setReaderBookmarks] = useState(() => {
|
||||
try { return JSON.parse(localStorage.getItem('reader-bookmarks') || '{}'); } catch { return {}; }
|
||||
});
|
||||
const [readerBookmarksPanelOpen, setReaderBookmarksPanelOpen] = useState(false);
|
||||
const [readerJumpVerse, setReaderJumpVerse] = useState(null); // verse number to scroll to + flash after a jump
|
||||
const [readerCrossRefs, setReaderCrossRefs] = useState(null);
|
||||
const [readerCrossRefsLoading, setReaderCrossRefsLoading] = useState(false);
|
||||
const [readerShowCrossRefs, setReaderShowCrossRefs] = useState(false);
|
||||
const _readerCrossRefCacheRef = useRef({});
|
||||
const [readerSearch, setReaderSearch] = useState('');
|
||||
const [readerSearchActive, setReaderSearchActive] = useState(false);
|
||||
const [readerSearchScope, setReaderSearchScope] = useState('chapter'); // 'chapter' | 'bible'
|
||||
const [bibleIndexStatus, setBibleIndexStatus] = useState('idle'); // 'idle' | 'loading' | 'ready' | 'error'
|
||||
const _bibleIndexCacheRef = useRef({}); // translation -> flat verse array
|
||||
const [readerInkByPage, setReaderInkByPage] = useState(() => {
|
||||
try { return JSON.parse(localStorage.getItem('readerInkByPage') ?? '{}'); }
|
||||
catch { return {}; }
|
||||
});
|
||||
const [readerTextHighlights, setReaderTextHighlights] = useState(() => {
|
||||
try {
|
||||
const val = JSON.parse(localStorage.getItem('readerTextHighlights') || '[]');
|
||||
return Array.isArray(val) ? val : [];
|
||||
} catch { return []; }
|
||||
});
|
||||
// Debounce timer ref so rapid stroke updates don't flood the server
|
||||
const inkSaveTimerRef = useRef({});
|
||||
|
||||
// The HelloAO API has no search endpoint, so whole-Bible search fetches the
|
||||
// entire translation once (~7MB) and searches an in-memory flat verse index
|
||||
// client-side. Cached per translation for the rest of the session (not persisted
|
||||
// across reloads, to avoid eating into localStorage/IndexedDB quota for a cache
|
||||
// that's cheap enough to rebuild once per visit).
|
||||
const loadBibleIndex = async () => {
|
||||
if (_bibleIndexCacheRef.current.BSB) {
|
||||
setBibleIndexStatus('ready');
|
||||
return;
|
||||
}
|
||||
setBibleIndexStatus('loading');
|
||||
try {
|
||||
// Check IndexedDB cache before hitting the network (~7 MB fetch)
|
||||
const cached = await getBibleIndexFromIDB('BSB');
|
||||
if (cached?.flat && Date.now() - cached.cachedAt < BIBLE_IDB_TTL) {
|
||||
_bibleIndexCacheRef.current.BSB = cached.flat;
|
||||
setBibleIndexStatus('ready');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch('https://bible.helloao.org/api/BSB/complete.json');
|
||||
if (!res.ok) throw new Error('Unable to load the full Bible for search.');
|
||||
const data = await res.json();
|
||||
const flat = [];
|
||||
for (const book of data.books ?? []) {
|
||||
for (const ch of book.chapters ?? []) {
|
||||
const verses = parseBibleChapter({ chapter: ch.chapter });
|
||||
for (const v of verses) {
|
||||
if (v.text) {
|
||||
flat.push({ bookAbbrev: book.id, bookName: book.commonName || book.name, chapter: ch.chapter.number, verse: v.number, text: v.text });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_bibleIndexCacheRef.current.BSB = flat;
|
||||
setBibleIndexStatus('ready');
|
||||
// Persist for future visits — non-blocking
|
||||
saveBibleIndexToIDB('BSB', flat);
|
||||
} catch {
|
||||
setBibleIndexStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
const loadReaderChapter = async (bookAbbrev, chapterNumber) => {
|
||||
setReaderLoading(true);
|
||||
setReaderError('');
|
||||
try {
|
||||
const res = await fetch(`https://bible.helloao.org/api/BSB/${bookAbbrev}/${chapterNumber}.json`);
|
||||
if (!res.ok) throw new Error('Unable to load chapter.');
|
||||
const data = await res.json();
|
||||
const verses = parseBibleChapter(data);
|
||||
if (!Array.isArray(verses) || verses.length === 0) {
|
||||
throw new Error('Invalid Bible data returned.');
|
||||
}
|
||||
setReaderVerses(verses);
|
||||
setReaderTotalChapters(data.book?.numberOfChapters ?? chapterNumber);
|
||||
} catch (err) {
|
||||
setReaderError(err.message || 'Failed to load chapter.');
|
||||
setReaderVerses([]);
|
||||
} finally {
|
||||
setReaderLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPage !== 'reader') return;
|
||||
loadReaderChapter(readerBookAbbrev, readerChapter);
|
||||
}, [currentPage, readerBookAbbrev, readerChapter]);
|
||||
|
||||
// After navigating to a verse (from the bookmarks panel or a whole-Bible search
|
||||
// result), scroll to it and briefly flash its background so it's easy to spot.
|
||||
useEffect(() => {
|
||||
if (readerJumpVerse == null || readerLoading) return;
|
||||
const el = document.getElementById(`reader-verse-${readerJumpVerse}`);
|
||||
if (!el) return;
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
const prevTransition = el.style.transition;
|
||||
const prevBackground = el.style.backgroundColor;
|
||||
el.style.transition = 'background-color 0.4s';
|
||||
el.style.backgroundColor = '#fef08a';
|
||||
const t = window.setTimeout(() => {
|
||||
el.style.backgroundColor = prevBackground;
|
||||
window.setTimeout(() => { el.style.transition = prevTransition; }, 400);
|
||||
}, 1200);
|
||||
setReaderJumpVerse(null);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [readerVerses, readerLoading, readerJumpVerse]);
|
||||
|
||||
// Navigates the reader to a specific verse (bookmarks panel, search results) and
|
||||
// scrolls/flashes it once that chapter's verses have loaded.
|
||||
const jumpToReaderVerse = (bookAbbrev, chapter, verseNumber) => {
|
||||
setReaderBookAbbrev(bookAbbrev);
|
||||
setReaderChapter(chapter);
|
||||
setReaderSelectedVerse(null);
|
||||
setReaderInterlinear(null);
|
||||
setReaderCrossRefs(null);
|
||||
setReaderSearch('');
|
||||
setReaderSearchActive(false);
|
||||
setReaderBookmarksPanelOpen(false);
|
||||
setReaderJumpVerse(verseNumber);
|
||||
};
|
||||
|
||||
const readerGoToPreviousChapter = () => {
|
||||
if (readerChapter > 1) { setReaderChapter((c) => c - 1); setReaderSelectedVerse(null); setReaderCrossRefs(null); setReaderSearch(''); setReaderSearchActive(false); }
|
||||
};
|
||||
|
||||
const readerGoToNextChapter = () => {
|
||||
if (readerChapter < readerTotalChapters) { setReaderChapter((c) => c + 1); setReaderSelectedVerse(null); setReaderCrossRefs(null); setReaderSearch(''); setReaderSearchActive(false); }
|
||||
};
|
||||
|
||||
const handleReaderBookChange = (abbrev) => {
|
||||
setReaderBookAbbrev(abbrev);
|
||||
setReaderChapter(1);
|
||||
setReaderSelectedVerse(null);
|
||||
setReaderInterlinear(null);
|
||||
setReaderCrossRefs(null);
|
||||
setReaderSearch('');
|
||||
setReaderSearchActive(false);
|
||||
};
|
||||
|
||||
const loadReaderInterlinear = async (bookAbbrev) => {
|
||||
if (_readerInterlinearCacheRef.current[bookAbbrev]) {
|
||||
setReaderInterlinear(_readerInterlinearCacheRef.current[bookAbbrev]);
|
||||
return;
|
||||
}
|
||||
setReaderInterlinearLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/interlinear/${bookAbbrev}.json`);
|
||||
if (!res.ok) throw new Error('no interlinear');
|
||||
const data = await res.json();
|
||||
_readerInterlinearCacheRef.current[bookAbbrev] = data;
|
||||
setReaderInterlinear(data);
|
||||
} catch {
|
||||
setReaderInterlinear(null);
|
||||
} finally {
|
||||
setReaderInterlinearLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPage !== 'reader') return;
|
||||
loadReaderInterlinear(readerBookAbbrev);
|
||||
setReaderSelectedVerse(null);
|
||||
}, [currentPage, readerBookAbbrev]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPage !== 'reader' || !readerShowCrossRefs) return;
|
||||
loadReaderCrossRefs(readerBookAbbrev, readerChapter);
|
||||
}, [currentPage, readerBookAbbrev, readerChapter, readerShowCrossRefs]);
|
||||
|
||||
// Bookmarks, ink, highlights, cross-refs
|
||||
const toggleReaderBookmark = (verseKey) => {
|
||||
setReaderBookmarks((prev) => {
|
||||
const next = { ...prev };
|
||||
if (next[verseKey]) {
|
||||
delete next[verseKey];
|
||||
} else {
|
||||
const usedColors = Object.values(next);
|
||||
const color = BOOKMARK_COLORS.find((c) => !usedColors.includes(c)) ?? BOOKMARK_COLORS[0];
|
||||
next[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) => {
|
||||
const next = { ...prev, [key]: strokes };
|
||||
try { localStorage.setItem('readerInkByPage', JSON.stringify(next)); } catch {}
|
||||
return next;
|
||||
});
|
||||
// Debounce server sync — wait 1 s after last stroke before sending
|
||||
clearTimeout(inkSaveTimerRef.current[key]);
|
||||
inkSaveTimerRef.current[key] = setTimeout(() => {
|
||||
fetch(`/api/reader/ink/${encodeURIComponent(bookAbbrev)}/${encodeURIComponent(chapter)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ strokes }),
|
||||
}).catch(() => {}); // silent — localStorage already saved locally
|
||||
}, 1000);
|
||||
}, []);
|
||||
|
||||
const cycleBookmarkColor = (verseKey) => {
|
||||
setReaderBookmarks((prev) => {
|
||||
const cur = prev[verseKey];
|
||||
if (!cur) return prev;
|
||||
const idx = BOOKMARK_COLORS.indexOf(cur);
|
||||
const next = { ...prev, [verseKey]: BOOKMARK_COLORS[(idx + 1) % BOOKMARK_COLORS.length] };
|
||||
try { localStorage.setItem('reader-bookmarks', JSON.stringify(next)); } catch {}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const addTextHighlight = (highlight) => {
|
||||
const id = (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
|
||||
setReaderTextHighlights(prev => {
|
||||
const next = [...prev, { id, ...highlight }];
|
||||
try { localStorage.setItem('readerTextHighlights', JSON.stringify(next)); } catch {}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const removeTextHighlight = (id) => {
|
||||
setReaderTextHighlights(prev => {
|
||||
const next = prev.filter(h => h.id !== id);
|
||||
try { localStorage.setItem('readerTextHighlights', JSON.stringify(next)); } catch {}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const loadReaderCrossRefs = async (bookAbbrev, chapterNumber) => {
|
||||
const cacheKey = `${bookAbbrev}/${chapterNumber}`;
|
||||
if (_readerCrossRefCacheRef.current[cacheKey]) {
|
||||
setReaderCrossRefs(_readerCrossRefCacheRef.current[cacheKey]);
|
||||
return;
|
||||
}
|
||||
setReaderCrossRefsLoading(true);
|
||||
try {
|
||||
const res = await fetch(`https://bible.helloao.org/api/d/open-cross-ref/${bookAbbrev}/${chapterNumber}.json`);
|
||||
if (!res.ok) throw new Error('no cross-refs');
|
||||
const data = await res.json();
|
||||
const byVerse = {};
|
||||
for (const item of data.chapter?.content ?? []) {
|
||||
if (item.references?.length) byVerse[item.verse] = item.references;
|
||||
}
|
||||
_readerCrossRefCacheRef.current[cacheKey] = byVerse;
|
||||
setReaderCrossRefs(byVerse);
|
||||
} catch {
|
||||
setReaderCrossRefs(null);
|
||||
} finally {
|
||||
setReaderCrossRefsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Merges reader ink loaded from the server at sign-in; server wins over local.
|
||||
const mergeServerInk = useCallback((serverInk) => {
|
||||
setReaderInkByPage((local) => {
|
||||
const merged = { ...local, ...serverInk };
|
||||
try { localStorage.setItem('readerInkByPage', JSON.stringify(merged)); } catch {}
|
||||
return merged;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
readerBookAbbrev, setReaderBookAbbrev,
|
||||
readerChapter, setReaderChapter,
|
||||
readerVerses,
|
||||
readerTotalChapters,
|
||||
readerLoading,
|
||||
readerError,
|
||||
readerInterlinear,
|
||||
readerSelectedVerse, setReaderSelectedVerse,
|
||||
readerFontSize, setReaderFontSize,
|
||||
readerBookmarks, setReaderBookmarks,
|
||||
readerBookmarksPanelOpen, setReaderBookmarksPanelOpen,
|
||||
readerCrossRefs, setReaderCrossRefs,
|
||||
readerCrossRefsLoading,
|
||||
readerShowCrossRefs, setReaderShowCrossRefs,
|
||||
readerSearch, setReaderSearch,
|
||||
readerSearchActive, setReaderSearchActive,
|
||||
readerSearchScope, setReaderSearchScope,
|
||||
readerInkByPage,
|
||||
updateReaderPageInk,
|
||||
mergeServerInk,
|
||||
readerTextHighlights,
|
||||
addTextHighlight,
|
||||
removeTextHighlight,
|
||||
bibleIndexStatus,
|
||||
loadBibleIndex,
|
||||
_bibleIndexCacheRef,
|
||||
loadReaderChapter,
|
||||
handleReaderBookChange,
|
||||
readerGoToPreviousChapter,
|
||||
readerGoToNextChapter,
|
||||
jumpToReaderVerse,
|
||||
toggleReaderBookmark,
|
||||
cycleBookmarkColor,
|
||||
loadReaderCrossRefs,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user