diff --git a/public/sw.js b/public/sw.js
new file mode 100644
index 0000000..3487066
--- /dev/null
+++ b/public/sw.js
@@ -0,0 +1,36 @@
+const CACHE = 'bible-api-v1';
+const CACHEABLE = ['https://bible.helloao.org', 'https://bolls.life'];
+
+self.addEventListener('install', () => self.skipWaiting());
+
+self.addEventListener('activate', (event) => {
+ event.waitUntil(
+ caches.keys()
+ .then(keys => Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k))))
+ .then(() => self.clients.claim())
+ );
+});
+
+// Network-first, fall back to cache for external Bible API requests.
+self.addEventListener('fetch', (event) => {
+ if (!CACHEABLE.some(origin => event.request.url.startsWith(origin))) return;
+
+ event.respondWith(
+ fetch(event.request)
+ .then(response => {
+ if (response.ok) {
+ const clone = response.clone();
+ caches.open(CACHE).then(cache => cache.put(event.request, clone));
+ }
+ return response;
+ })
+ .catch(() =>
+ caches.open(CACHE)
+ .then(cache => cache.match(event.request))
+ .then(cached => cached ?? new Response('{"error":"offline"}', {
+ status: 503,
+ headers: { 'Content-Type': 'application/json' },
+ }))
+ )
+ );
+});
diff --git a/src/App.jsx b/src/App.jsx
index c571571..ae9edf9 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -1,7 +1,15 @@
import { useEffect, useRef, useState } from 'react';
-import { createPortal } from 'react-dom';
-import DOMPurify from 'dompurify';
import mammoth from 'mammoth';
+
+import { AppContext, useApp } from './context/AppContext.js';
+import AdminPage from './pages/AdminPage.jsx';
+import AuthPage from './pages/AuthPage.jsx';
+import HomePage from './pages/HomePage.jsx';
+import ImportPage from './pages/ImportPage.jsx';
+import ReaderPage from './pages/ReaderPage.jsx';
+import SettingsPage from './pages/SettingsPage.jsx';
+import SetupPage from './pages/SetupPage.jsx';
+import StudyPage from './pages/StudyPage.jsx';
import {
AlignmentType,
BorderStyle,
@@ -44,7 +52,7 @@ import {
adminDeleteProject,
} from './syncService.js';
-const COMMENTARY_OPTIONS = [
+export const COMMENTARY_OPTIONS = [
{ id: 'matthew-henry', name: 'Matthew Henry' },
{ id: 'john-gill', name: 'John Gill' },
{ id: 'jamieson-fausset-brown', name: 'Jamieson-Fausset-Brown' },
@@ -53,7 +61,7 @@ const COMMENTARY_OPTIONS = [
{ id: 'tyndale', name: 'Tyndale Open Study Notes' },
];
-const STUDY_TABS = [
+export const STUDY_TABS = [
{ id: 'notes', label: 'Notes' },
{ id: 'crossRefs', label: 'Cross-Refs' },
{ id: 'wordStudy', label: 'Word Study' },
@@ -61,7 +69,7 @@ const STUDY_TABS = [
{ id: 'script', label: 'Script' },
];
-const bookOptions = [
+export const bookOptions = [
{ name: 'Genesis', abbrev: 'GEN' },
{ name: 'Exodus', abbrev: 'EXO' },
{ name: 'Leviticus', abbrev: 'LEV' },
@@ -130,7 +138,7 @@ const bookOptions = [
{ name: 'Revelation', abbrev: 'REV' },
];
-const NT_BOOK_NUMBER = {
+export const NT_BOOK_NUMBER = {
MAT: 40, MRK: 41, LUK: 42, JHN: 43, ACT: 44,
ROM: 45, '1CO': 46, '2CO': 47, GAL: 48, EPH: 49,
PHP: 50, COL: 51, '1TH': 52, '2TH': 53, '1TI': 54,
@@ -187,7 +195,7 @@ function chunkSpansNextChapter(project, startChapterIndex, chunk) {
return startChapter?.bookAbbrev && startChapter.bookAbbrev === nextChapter?.bookAbbrev;
}
-function formatChunkReference(project, startChapterIndex, chunk, separator = '-') {
+export function formatChunkReference(project, startChapterIndex, chunk, separator = '-') {
if (!project || !chunk) return '';
const startChapter = project.chapters?.[startChapterIndex];
if (!startChapter) return '';
@@ -313,7 +321,7 @@ function migrateLegacyLocalDataToUser(userId) {
}
/** Switches the active local-storage namespace and returns the freshly loaded index for it. */
-function switchStorageUser(userId) {
+export function switchStorageUser(userId) {
activeStorageUserId = userId ?? null;
if (userId) migrateLegacyLocalDataToUser(userId);
return loadProjectIndex();
@@ -397,7 +405,7 @@ function buildChapterSummary(project) {
.join(', ');
}
-function formatRelativeDate(ts) {
+export function formatRelativeDate(ts) {
if (!ts) return '';
const diff = Date.now() - ts;
const mins = Math.floor(diff / 60000);
@@ -413,6 +421,8 @@ function formatRelativeDate(ts) {
// Export / prompt builders (exported for testing)
// ---------------------------------------------------------------------------
+const escHtml = (s) => String(s ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+
export function buildExportHtml(project) {
const style = `
body { font-family: Georgia, serif; color: #0f172a; margin: 0; padding: 32px; }
@@ -440,28 +450,28 @@ export function buildExportHtml(project) {
const chapters = Array.isArray(project.chapters) ? project.chapters : [];
const chunksHtml = chapters.map((ch, chapterIndex) => {
- const chapterHeader = `
${ch.book} ${ch.chapter}
`;
+ const chapterHeader = `${escHtml(ch.book)} ${escHtml(ch.chapter)}
`;
const chunkSections = ch.chunks.map((chunk) => {
const scripture = formatChunkReference(project, chapterIndex, chunk, '-');
const versesText = getChunkVerseEntries(project, chapterIndex, chunk)
- .map((verse) => `${verse.chapter}:${verse.number} ${verse.text}
`)
+ .map((verse) => `${escHtml(verse.chapter)}:${escHtml(verse.number)} ${escHtml(verse.text)}
`)
.join('');
- const observation = (chunk.observation ?? '').trim().replace(/\n/g, '
') || 'No observation.';
- const interpretation = (chunk.interpretation ?? '').trim().replace(/\n/g, '
') || 'No interpretation.';
- const application = (chunk.application ?? '').trim().replace(/\n/g, '
') || 'No application.';
+ const observation = escHtml((chunk.observation ?? '').trim()).replace(/\n/g, '
') || 'No observation.';
+ const interpretation = escHtml((chunk.interpretation ?? '').trim()).replace(/\n/g, '
') || 'No interpretation.';
+ const application = escHtml((chunk.application ?? '').trim()).replace(/\n/g, '
') || 'No application.';
const crossRefsHtml = (chunk.crossReferences ?? []).length > 0
- ? `CROSS-REFERENCES: ${chunk.crossReferences.join(', ')}
`
+ ? `CROSS-REFERENCES: ${chunk.crossReferences.map(escHtml).join(', ')}
`
: '';
const greekRows = chunk.greekWords.map((word) => `
- | ${word.strongNumber} |
- ${word.lexeme || ''} |
- ${word.transliteration || ''} |
- ${word.partOfSpeech || ''} |
- ${word.shortDefinition || ''} |
+ ${escHtml(word.strongNumber)} |
+ ${escHtml(word.lexeme)} |
+ ${escHtml(word.transliteration)} |
+ ${escHtml(word.partOfSpeech)} |
+ ${escHtml(word.shortDefinition)} |
`).join('');
const greekTable = wordTableHtml(greekRows);
@@ -470,7 +480,7 @@ export function buildExportHtml(project) {
if (word.definitionHtml) {
return `
-
${word.strongNumber} — ${word.lexeme || ''}
+
${escHtml(word.strongNumber)} — ${escHtml(word.lexeme)}
${word.definitionHtml}
`;
@@ -485,7 +495,7 @@ export function buildExportHtml(project) {
: `BibleHub · Blue Letter Bible`;
return `
-
${raw} — definition not found in auto-lookup
+
${escHtml(raw)} — definition not found in auto-lookup
Look it up manually: ${links}
`;
@@ -496,7 +506,7 @@ export function buildExportHtml(project) {
return `
- ${scripture}
+ ${escHtml(scripture)}
${versesText}
OBSERVATION:${observation}
@@ -519,13 +529,13 @@ export function buildExportHtml(project) {
-
${project.title}
+
${escHtml(project.title)}
-
${project.title}
-
${project.translation}
+
${escHtml(project.title)}
+
${escHtml(project.translation)}
${chunksHtml}
@@ -975,7 +985,7 @@ async function parseEpisodeListDocx(file) {
// Small, unambiguous icons for the reader's verse actions (replaces emoji, which
// don't reliably convey "bookmark this" vs. "already bookmarked" at a glance).
-function BookmarkIcon({ filled }) {
+export function BookmarkIcon({ filled }) {
return (
);
+
// ---------------------------------------------------------------------------
- // Shared read-only view — a ?share=TOKEN link, no login required at all.
- // Renders in a sandboxed iframe (scripts disabled) since the HTML embeds
- // user-authored notes that aren't HTML-escaped.
+ // Context value — every state, handler, and computed value pages need.
// ---------------------------------------------------------------------------
+ const contextValue = {
+ // Navigation
+ currentPage, setCurrentPage,
+ goHome,
+ authServerDown,
+ // Shared view
+ sharedViewToken,
+ sharedProject,
+ sharedError,
+ // Auth
+ authUser, setAuthUser,
+ authStatus,
+ authMode, setAuthMode,
+ authForm, setAuthForm,
+ authError, setAuthError,
+ authBusy, setAuthBusy,
+ authMfaPending, setAuthMfaPending,
+ authMfaCode, setAuthMfaCode,
+ authMfaUseBackup, setAuthMfaUseBackup,
+ setProjectIndex,
+ // Settings — MFA
+ mfaSetup, setMfaSetup,
+ mfaSetupCode, setMfaSetupCode,
+ mfaSetupError, setMfaSetupError,
+ mfaSetupBusy, setMfaSetupBusy,
+ mfaBackupCodes, setMfaBackupCodes,
+ mfaDisablePassword, setMfaDisablePassword,
+ mfaDisableError, setMfaDisableError,
+ // Settings — password / podcast
+ podcastNameInput, setPodcastNameInput,
+ podcastNameSaving, setPodcastNameSaving,
+ podcastNameSaved, setPodcastNameSaved,
+ changePasswordForm, setChangePasswordForm,
+ changePasswordBusy, setChangePasswordBusy,
+ changePasswordError, setChangePasswordError,
+ changePasswordSaved, setChangePasswordSaved,
+ // Home
+ projectIndex,
+ autoRestoredCount,
+ staleLocalProjects,
+ homeSearch, setHomeSearch,
+ homeSort, setHomeSort,
+ homeTagFilter, setHomeTagFilter,
+ renamingId, setRenamingId,
+ renameValue, setRenameValue,
+ openBibleReader,
+ openImportProject,
+ openNewProject,
+ pullLatestFromServer,
+ resumeProject,
+ renameProjectInStorage,
+ deleteProject,
+ // Audio
+ audioBook, setAudioBook,
+ audioNarrator, setAudioNarrator,
+ audioState,
+ handlePlayBookAudio,
+ handleStopBookAudio,
+ handleToggleBookAudioPause,
+ // Import
+ importBookAbbrev, setImportBookAbbrev,
+ importTranslation, setImportTranslation,
+ importTitle, setImportTitle,
+ importFile,
+ importPreview,
+ importBusy,
+ importError,
+ handleImportFileChange,
+ runEpisodeImport,
+ // Reader
+ 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,
+ readerAudioState,
+ bibleIndexStatus,
+ loadReaderChapter,
+ _bibleIndexCacheRef,
+ handleReaderBookChange,
+ readerGoToPreviousChapter,
+ readerGoToNextChapter,
+ jumpToReaderVerse,
+ toggleReaderBookmark,
+ cycleBookmarkColor,
+ loadReaderCrossRefs,
+ loadBibleIndex,
+ handlePlayReaderAudio,
+ handleToggleReaderAudioPause,
+ copyVerse,
+ // Setup
+ availableTranslations,
+ project, setProject,
+ setup,
+ titleEdited, setTitleEdited,
+ activeChapterIndex, setActiveChapterIndex,
+ activeChapter,
+ showAddChapterForm, setShowAddChapterForm,
+ rangeStart, setRangeStart,
+ rangeEnd, setRangeEnd,
+ verseSearch, setVerseSearch,
+ typedChunkStart, setTypedChunkStart,
+ typedChunkEnd, setTypedChunkEnd,
+ typedChunkNextEnd, setTypedChunkNextEnd,
+ typedChunkBulk, setTypedChunkBulk,
+ clickedSpanNextEnd, setClickedSpanNextEnd,
+ loadingChapter,
+ errorMessage,
+ statusMessage,
+ allChunks,
+ handleSetupField,
+ handleLoadChapter,
+ beginStudying,
+ handleVerseClick,
+ addTypedChunk,
+ addBulkTypedChunks,
+ addClickSpanChunk,
+ moveChunk,
+ deleteChunk,
+ updateProject,
+ // Study
+ selectedChunk,
+ selectedChunkChapterIndex,
+ selectedChunkChapter,
+ selectedChunkVerses,
+ selectedChunkGlobalIndex,
+ studyLayout, setStudyLayout,
+ activeStudyTab, setActiveStudyTab,
+ mobileStudyTab, setMobileStudyTab,
+ collapsedSections, setCollapsedSections,
+ interlinearData,
+ interlinearLoading,
+ interlinearError,
+ commentarySource, setCommentarySource,
+ commentaryData,
+ commentaryLoading,
+ commentaryError,
+ crossRefInput, setCrossRefInput,
+ suggestingCrossRefs,
+ crossRefSuggestions,
+ tagInput, setTagInput,
+ suggestModal, setSuggestModal,
+ suggestSelection, setSuggestSelection,
+ suggestingGreekForChunkId,
+ suggestingHebrewForChunkId,
+ goToPreviousChunk,
+ goToNextChunk,
+ updateChunk,
+ addCrossRef,
+ removeCrossRef,
+ suggestCrossRefsForChunk,
+ addSuggestedCrossRef,
+ addTag,
+ removeTag,
+ addGreekWord,
+ removeGreekWord,
+ updateChunkWord,
+ lookupWord,
+ suggestGreekWordsForChunk,
+ suggestHebrewWordsForChunk,
+ confirmSuggestWords,
+ speakOriginalWord,
+ loadVerseText,
+ importFinalScriptDocx,
+ // Admin
+ adminTab, setAdminTab,
+ adminUsers,
+ adminProjects,
+ adminLoading,
+ adminError,
+ adminViewProject, setAdminViewProject,
+ adminResetResult, setAdminResetResult,
+ loadAdminData,
+ // Functions defined inside App (cannot be module-level exports)
+ formatCrossRef,
+ buildGreekDefinitionHtml,
+ externalLookupLinks,
+ // Computed JSX
+ authStatus,
+ headerButtons,
+ };
+
+ return (
+
+
+
+ );
+};
+
+function AppRouter() {
+ const {
+ currentPage,
+ authUser,
+ authServerDown,
+ sharedViewToken,
+ sharedProject,
+ sharedError,
+ } = useApp();
+
if (sharedViewToken) {
if (sharedError) {
return (
@@ -3624,7 +3844,8 @@ const deleteProject = (id) => {
return (