Add fixed bottom chunk nav and IndexedDB Bible search cache

- Replace non-functional sticky nav with a fixed bottom bar (position: fixed)
  that stays visible while scrolling long study notes; hides in draw mode
- Add pb-20 to main so the bar never covers the last study section
- Cache whole-Bible search index (BSB complete.json, ~7MB) in IndexedDB
  with a 7-day TTL so repeated searches skip the network fetch entirely

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-08-13 16:08:25 -04:00
parent 22ad12e677
commit 90fd5c4e9e
3 changed files with 88 additions and 25 deletions
-2
View File
@@ -22,10 +22,8 @@ _Refreshed 2026-07-06 (multiple passes) — items already shipped have been remo
## UX / UI
### Study Page
- **Sticky bottom nav** — top Prev/Next chunk nav shipped (commit `103e20c`); a matching sticky bottom bar for long chunks would avoid scroll-back
### Reader
- **Whole-Bible search index isn't persisted** — the ~7MB `complete.json` fetch is cached in-memory only for the session; a page reload re-downloads it. Worth persisting to IndexedDB (not localStorage — too small) if this gets used often
- **Bookmark color picker is still an emoji button** (🎨) — the bookmark/copy icons became proper SVGs, but color-cycling didn't get the same treatment
### Home Page
+60
View File
@@ -1072,6 +1072,56 @@ export function CrossRefChip({ label, onRemove, loadVerseText }) {
);
}
// ---------------------------------------------------------------------------
// IndexedDB helpers for whole-Bible search cache
// ---------------------------------------------------------------------------
const BIBLE_IDB_NAME = 'study-app-bible-index';
const BIBLE_IDB_STORE = 'bible-index';
const BIBLE_IDB_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days
function openBibleIndexDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(BIBLE_IDB_NAME, 1);
req.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains(BIBLE_IDB_STORE)) {
db.createObjectStore(BIBLE_IDB_STORE, { keyPath: 'id' });
}
};
req.onsuccess = (e) => resolve(e.target.result);
req.onerror = () => reject(req.error);
});
}
async function getBibleIndexFromIDB(translation) {
try {
const db = await openBibleIndexDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(BIBLE_IDB_STORE, 'readonly');
const req = tx.objectStore(BIBLE_IDB_STORE).get(translation);
req.onsuccess = () => resolve(req.result ?? null);
req.onerror = () => reject(req.error);
});
} catch {
return null;
}
}
async function saveBibleIndexToIDB(translation, flat) {
try {
const db = await openBibleIndexDB();
await new Promise((resolve, reject) => {
const tx = db.transaction(BIBLE_IDB_STORE, 'readwrite');
const req = tx.objectStore(BIBLE_IDB_STORE).put({ id: translation, flat, cachedAt: Date.now() });
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
} catch {
// Non-fatal; in-memory cache still works for this session
}
}
// ---------------------------------------------------------------------------
// App component
// ---------------------------------------------------------------------------
@@ -1388,6 +1438,14 @@ const App = () => {
}
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();
@@ -1404,6 +1462,8 @@ const App = () => {
}
_bibleIndexCacheRef.current.BSB = flat;
setBibleIndexStatus('ready');
// Persist for future visits — non-blocking
saveBibleIndexToIDB('BSB', flat);
} catch {
setBibleIndexStatus('error');
}
+28 -23
View File
@@ -76,7 +76,7 @@ export default function StudyPage() {
</div>
</header>
<main className="mx-auto max-w-7xl px-4 py-4 sm:py-8 sm:px-6 lg:px-8">
<main className="mx-auto max-w-7xl px-4 py-4 pb-20 sm:py-8 sm:pb-20 sm:px-6 lg:px-8">
<section className={`grid min-w-0 gap-6 ${studyLayout === 'split' ? '' : 'lg:grid-cols-[260px_1fr]'}`}>
{/* Sidebar */}
<aside className={`min-w-0 rounded-3xl border border-slate-200 bg-white p-4 sm:p-6 shadow-panel max-sm:hidden ${studyLayout === 'split' ? 'hidden' : ''}`}>
@@ -902,28 +902,6 @@ export default function StudyPage() {
</div>
)}
{/* Prev / Next — sticky */}
<div className="sticky bottom-0 z-10 -mx-4 sm:-mx-6 mt-6 flex items-center justify-between gap-3 border-t border-slate-200 bg-white/95 px-4 py-3 backdrop-blur-sm sm:px-6">
<button
type="button"
onClick={goToPreviousChunk}
disabled={selectedChunkGlobalIndex <= 0}
className="rounded-2xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 transition hover:border-slate-400 disabled:cursor-not-allowed disabled:opacity-40"
>
← Previous Chunk
</button>
<span className="text-xs text-slate-400">
{selectedChunkGlobalIndex + 1} / {allChunks.length}
</span>
<button
type="button"
onClick={goToNextChunk}
disabled={selectedChunkGlobalIndex >= allChunks.length - 1}
className="rounded-2xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 transition hover:border-slate-400 disabled:cursor-not-allowed disabled:opacity-40"
>
Next Chunk →
</button>
</div>
</div>
) : (
<div className="mt-6 rounded-3xl border border-slate-200 bg-slate-50 p-8 text-center text-slate-500">
@@ -935,6 +913,33 @@ export default function StudyPage() {
</section>
</main>
{/* Prev / Next — fixed bottom bar, visible while scrolling long chunks */}
{selectedChunk && studyLayout !== 'annotate' && (
<div className="fixed bottom-0 inset-x-0 z-20 border-t border-slate-200 bg-white/95 backdrop-blur-sm">
<div className="mx-auto flex max-w-7xl items-center justify-between gap-3 px-4 py-3 sm:px-6 lg:px-8">
<button
type="button"
onClick={goToPreviousChunk}
disabled={selectedChunkGlobalIndex <= 0}
className="rounded-2xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 transition hover:border-slate-400 disabled:cursor-not-allowed disabled:opacity-40"
>
← Previous Chunk
</button>
<span className="text-xs text-slate-400">
{selectedChunkGlobalIndex + 1} / {allChunks.length}
</span>
<button
type="button"
onClick={goToNextChunk}
disabled={selectedChunkGlobalIndex >= allChunks.length - 1}
className="rounded-2xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 transition hover:border-slate-400 disabled:cursor-not-allowed disabled:opacity-40"
>
Next Chunk →
</button>
</div>
</div>
)}
{/* Greek word picker modal */}
{suggestModal && createPortal(
<div