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 ( @@ -983,7 +993,7 @@ function BookmarkIcon({ filled }) { ); } -function CopyIcon() { +export function CopyIcon() { return ( @@ -993,7 +1003,7 @@ function CopyIcon() { } // A cross-reference chip that fetches and shows the referenced verse text on hover -function CrossRefChip({ label, onRemove, loadVerseText }) { +export function CrossRefChip({ label, onRemove, loadVerseText }) { const [hovered, setHovered] = useState(false); const [verseText, setVerseText] = useState(null); const [error, setError] = useState(''); @@ -3598,11 +3608,221 @@ const deleteProject = (id) => {
); + // --------------------------------------------------------------------------- - // 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 (
- 🔗 Read-only shared view — go to Bible Study Project + 🔗 Read-only shared view —{' '} + go to Bible Study Project