import { lazy, Suspense, useEffect, useRef, useState } from 'react'; import { getPlanSchedule, nearestReadingDay } from './utils/readingPlans.js'; import { applySystemChrome } from './utils/systemChrome.js'; import { AppContext, useApp } from './context/AppContext.js'; import { ToastProvider, useToast } from './components/Toasts.jsx'; import CommandPalette from './components/CommandPalette.jsx'; import ShortcutsHelp from './components/ShortcutsHelp.jsx'; import SharedPasscodeForm from './components/SharedPasscodeForm.jsx'; import SharedOutlineView from './components/SharedOutlineView.jsx'; import Tooltips from './components/Tooltips.jsx'; import { useAudio } from './hooks/useAudio.js'; import { useAuth } from './hooks/useAuth.js'; import { useBibleReader } from './hooks/useBibleReader.js'; import { useProjectLibrary } from './hooks/useProjectLibrary.js'; import { useHistoryNav } from './hooks/useHistoryNav.js'; import { usePrayers } from './hooks/usePrayers.js'; import { useReadingHistory } from './hooks/useReadingHistory.js'; import { useReadingPlans } from './hooks/useReadingPlans.js'; import { useSharedView } from './hooks/useSharedView.js'; import HomePage from './pages/HomePage.jsx'; import { themeVars } from './components/reader/readerThemes.js'; import { joinStudy, loadRemoteProject, restoreStudyVersion, saveRemoteProject } from './syncService.js'; import { getSyncBase, setSyncBase } from './utils/syncBase.js'; import { bookOptions, buildExportHtml, loadProjectIndex, makeId, parseBibleChapter, switchStorageUser, } from './utils/bibleUtils.jsx'; import { loadProjectById, migrateOldStorageKeys, saveProjectToStorage } from './utils/projectStorage.js'; import { knownOnServer, unsyncedStudies } from './utils/syncQueue.js'; import { fetchStudyChapter } from './utils/studyText.js'; import { mergeStudies } from './utils/mergeStudies.js'; // Less-common pages are split into their own chunks so the first load only // downloads what's needed to show the home screen. const AdminPage = lazy(() => import('./pages/AdminPage.jsx')); const AuthPage = lazy(() => import('./pages/AuthPage.jsx')); const ImportPage = lazy(() => import('./pages/ImportPage.jsx')); const ReaderPage = lazy(() => import('./pages/ReaderPage.jsx')); const SettingsPage = lazy(() => import('./pages/SettingsPage.jsx')); const SetupPage = lazy(() => import('./pages/SetupPage.jsx')); const StudyPage = lazy(() => import('./pages/StudyPage.jsx')); const OutlinePage = lazy(() => import('./pages/OutlinePage.jsx')); const ScriptPage = lazy(() => import('./pages/ScriptPage.jsx')); const ExportPage = lazy(() => import('./pages/ExportPage.jsx')); const PronunciationPage = lazy(() => import('./pages/PronunciationPage.jsx')); const ReadingPlansPage = lazy(() => import('./pages/ReadingPlansPage.jsx')); const PrayerPage = lazy(() => import('./pages/PrayerPage.jsx')); // --------------------------------------------------------------------------- // App component // --------------------------------------------------------------------------- // The page each "Home page" choice in Settings opens. const START_PAGES = { studies: 'home', reader: 'reader', plans: 'reading-plans' }; // The user's chosen home page, read straight from this device's saved preference so the app // starts on it from the very first render (no flash of another page first). function savedStartPage() { try { return START_PAGES[localStorage.getItem('start-page')] ?? 'home'; } catch { return 'home'; } } const AppInner = () => { const { toast, setTheme: setToastTheme } = useToast(); const [project, setProject] = useState(null); // 'home' | 'setup' | 'study' | 'outline' | 'script' | 'export' | 'reader' | 'import' | 'pronunciation' | 'settings' | 'admin' | 'reading-plans' | 'prayer' const [currentPage, setCurrentPage] = useState(savedStartPage); const currentPageRef = useRef(currentPage); currentPageRef.current = currentPage; const [projectIndex, setProjectIndex] = useState([]); const [saveStatus, setSaveStatus] = useState(''); const saveTimerRef = useRef(null); const [syncStatus, setSyncStatus] = useState(''); // '' | 'syncing' | 'synced' | 'error' const [studyBuildBusy, setStudyBuildBusy] = useState(false); const readingPlans = useReadingPlans(); // The plan day currently open in the Reader ({ progressId, dayIndex }) — set when // you read from a plan, cleared when you leave the Reader. While set, the Reader // uses that plan's own translation. const [readerPlan, setReaderPlan] = useState(null); const readerPlanProgress = readerPlan ? readingPlans.planProgress.find((p) => p.id === readerPlan.progressId) ?? null : null; const reader = useBibleReader(currentPage, readerPlanProgress?.translation ?? null); const prayerJournal = usePrayers(); const [prayerDraft, setPrayerDraft] = useState(null); // a verse to start a prayer with (from the Reader) const readingHistory = useReadingHistory({ active: currentPage === 'reader', book: reader.readerBookAbbrev, chapter: reader.readerChapter }); const audio = useAudio(reader.readerBookAbbrev, reader.readerChapter, reader.readerTranslation); useEffect(() => { if (currentPage !== 'reader') setReaderPlan(null); }, [currentPage]); // A new page starts at the top. Without this the scroll position of the previous page carries over, and on // iPad content scrolled under the top edge makes the system blur the app bar. useEffect(() => { const scroller = document.scrollingElement ?? document.documentElement; if (scroller) scroller.scrollTop = 0; }, [currentPage]); // Keep the browser/OS chrome (status bar area, overscroll, scrollbars) in the chosen theme useEffect(() => { applySystemChrome(reader.readerTheme); setToastTheme(reader.readerTheme); }, [reader.readerTheme, setToastTheme]); const { authUser, setAuthUser, authServerDown, signIn, logout } = useAuth({ onSignIn: (user) => { setProjectIndex(switchStorageUser(user.id)); // Load reader ink from the server; server wins over any stale localStorage copy fetch('/api/reader/ink') .then((r) => r.ok ? r.json() : null) .then((serverInk) => { if (serverInk) reader.mergeServerInk(serverInk); }) .catch(() => {}); // Same for reading plan progress — pullServerReadingProgress surfaces // a failed GET via the same error banner a failed push uses, instead // of silently leaving stale/empty local state in place. readingPlans.pullServerReadingProgress(); // Bookmarks and highlights: reconcile this device's copy with the account's. reader.pullServerMarks(); // Theme, default translation, layout, commentary and reading position. reader.pullServerPrefs(); // What you've read, merged with this device's. readingHistory.pullReadingHistory(); prayerJournal.pullPrayers(); }, onSignOut: () => { reader.stopMarksSync(); reader.stopPrefsSync(); reader.stopInkSync(); readingHistory.stopReadingHistory(); prayerJournal.stopPrayers(); readingPlans.stopReadingPlansSync(); setProjectIndex(switchStorageUser(null)); goStudies(); }, }); const library = useProjectLibrary({ authUser, project, setProject, setProjectIndex, setCurrentPage, }); // Opening an invite link (?join=TOKEN) while signed in adds that study to your list, ready to edit. useEffect(() => { if (!authUser) return; const token = new URLSearchParams(window.location.search).get('join'); if (!token) return; window.history.replaceState({}, '', window.location.pathname); joinStudy(token).then(async (joined) => { if (!joined.ok) { toast(joined.error ?? 'Could not use that invite link.', { tone: 'error' }); return; } const full = await loadRemoteProject(joined.data.projectId); if (full.ok) { saveProjectToStorage(full.data); setSyncBase(full.data.id, full.data.lastEdited); setProjectIndex(loadProjectIndex()); } library.refreshLibrary(); toast(`You can now edit “${joined.data.title}”. It's in My Studies.`); }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [authUser]); // ⌘K search/go-to, the "?" shortcut list, and requests other pages make of the Reader. const [paletteOpen, setPaletteOpen] = useState(false); const [helpOpen, setHelpOpen] = useState(false); const [readerRequest, setReaderRequest] = useState(null); // { search: 'text' } from the palette const [settingsFocus, setSettingsFocus] = useState(null); // section Settings should scroll to useEffect(() => { const onKey = (e) => { if ((e.metaKey || e.ctrlKey) && !e.altKey && e.key.toLowerCase() === 'k') { e.preventDefault(); setHelpOpen(false); setPaletteOpen((o) => !o); return; } if (e.metaKey || e.ctrlKey || e.altKey) return; const t = e.target; if (t?.tagName === 'INPUT' || t?.tagName === 'TEXTAREA' || t?.tagName === 'SELECT' || t?.isContentEditable) return; if (e.key === '?') { e.preventDefault(); setPaletteOpen(false); setHelpOpen((o) => !o); } else if (e.key === '/' && currentPageRef.current !== 'reader') { e.preventDefault(); setPaletteOpen(true); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, []); // The browser tab and history show where you are ("Romans 8 · Bible Study", "Luke — Outline · Bible Study") useEffect(() => { const names = { home: 'My Studies', setup: 'Chunks', study: 'Study', outline: 'Outline', script: 'Script', export: 'Export', import: 'Import', pronunciation: 'Pronunciation Guide', settings: 'Settings', admin: 'Admin', 'reading-plans': 'Reading Plans', prayer: 'Prayer journal', }; let page = names[currentPage] ?? ''; if (currentPage === 'reader') { const book = bookOptions.find((b) => b.abbrev === reader.readerBookAbbrev)?.name ?? ''; page = `${book} ${reader.readerChapter}`.trim(); } else if (project?.title && ['setup', 'study', 'outline', 'script', 'export', 'pronunciation'].includes(currentPage)) { page = `${project.title} — ${page}`; } document.title = page ? `${page} · Bible Study` : 'Bible Study'; }, [currentPage, project?.title, reader.readerBookAbbrev, reader.readerChapter]); // Back/Forward move between pages instead of leaving the app. (Not for ?share= links: that page is separate.) useHistoryNav({ page: currentPage, setPage: setCurrentPage, hasProject: !!project, enabled: !new URLSearchParams(window.location.search).has('share') && !new URLSearchParams(window.location.search).has('outline'), onArrive: (page) => { if (page === 'home') setProjectIndex(loadProjectIndex()); }, }); // Startup: migrate old keys and load index useEffect(() => { migrateOldStorageKeys(); setProjectIndex(loadProjectIndex()); }, []); const allChunks = project ? project.chapters.flatMap((ch) => ch.chunks) : []; // Autosave: 1s after the last edit, save locally, then sync to the server. A save still waiting out // that second is written straight away when you leave the study or the tab, so nothing typed is lost. // Opening a study doesn't count as editing it — saving then would stamp the server's copy as "newer" // and make your other devices think it changed. const savedSignatureRef = useRef({ id: null, signature: '' }); const pendingSaveRef = useRef(null); // { project, signature } waiting for the debounce const [syncConflict, setSyncConflict] = useState(null); // { id, serverEdited } when the server has a newer copy const conflictRef = useRef(null); conflictRef.current = syncConflict; const signatureOf = (p) => JSON.stringify({ ...p, lastEdited: 0 }); // Sends a study to the server, checked against the copy this device last saw. const pushProject = async (toSave, { force = false } = {}) => { setSyncStatus('syncing'); const result = await saveRemoteProject(toSave, { baseEdited: getSyncBase(toSave.id), force }); if (result.ok) { // The server's clock decides the version; keep this device's copy on the same number const serverEdited = result.data?.lastEdited ?? toSave.lastEdited; setSyncBase(toSave.id, serverEdited); unsyncedStudies.remove(toSave.id); knownOnServer.add(toSave.id); const stored = loadProjectById(toSave.id); if (stored && stored.lastEdited === toSave.lastEdited && serverEdited !== toSave.lastEdited) { try { saveProjectToStorage({ ...stored, lastEdited: serverEdited }); setProjectIndex(loadProjectIndex()); } catch { /* the copy is already saved */ } } setSyncConflict((c) => (c?.id === toSave.id ? null : c)); setSyncStatus('synced'); window.setTimeout(() => setSyncStatus(''), 2500); } else if (result.status === 409) { setSyncConflict({ id: toSave.id, serverEdited: result.data?.serverEdited ?? null }); setSyncStatus('conflict'); } else { setSyncStatus('error'); // kept visible until the next successful sync } return result; }; const commitSave = async (toEdit) => { // Stamp lastEdited once so localStorage and server receive the same value const toSave = { ...toEdit, lastEdited: Date.now() }; // 1. Save locally (can fail when the device is out of storage) try { saveProjectToStorage(toSave); } catch (err) { console.error('Local save failed:', err); setSaveStatus(''); toast("Couldn't save on this device — storage is full. Free some space or export a backup.", { tone: 'error' }); } unsyncedStudies.add(toSave.id); setProjectIndex(loadProjectIndex()); setSaveStatus('Saved'); window.setTimeout(() => setSaveStatus(''), 1400); // 2. Sync to server with the same timestamped object (not while a conflict is waiting on a choice) if (conflictRef.current?.id === toSave.id) return; await pushProject(toSave); }; const flushPendingSave = () => { const pending = pendingSaveRef.current; if (!pending) return Promise.resolve(); window.clearTimeout(saveTimerRef.current); pendingSaveRef.current = null; savedSignatureRef.current = { id: pending.project.id, signature: pending.signature }; return commitSave(pending.project); }; const flushRef = useRef(flushPendingSave); flushRef.current = flushPendingSave; useEffect(() => { const flush = () => { if (document.visibilityState !== 'visible') flushRef.current(); }; window.addEventListener('pagehide', flush); document.addEventListener('visibilitychange', flush); return () => { window.removeEventListener('pagehide', flush); document.removeEventListener('visibilitychange', flush); }; }, []); useEffect(() => { if (!project) { flushPendingSave(); return undefined; } if (pendingSaveRef.current && pendingSaveRef.current.project.id !== project.id) flushPendingSave(); const signature = signatureOf(project); const last = savedSignatureRef.current; if (last.id !== project.id) { savedSignatureRef.current = { id: project.id, signature }; if (loadProjectById(project.id)) return undefined; // just opened an existing study: nothing to save yet } else if (last.signature === signature) { return undefined; } pendingSaveRef.current = { project, signature }; window.clearTimeout(saveTimerRef.current); saveTimerRef.current = window.setTimeout(() => flushRef.current(), 1000); return undefined; }, [project]); // eslint-disable-line react-hooks/exhaustive-deps // The two ways out of a conflict: keep this device's version, or take the server's. const keepMyVersion = async () => { if (!project) return; await pushProject({ ...project, lastEdited: Date.now() }, { force: true }); }; const useServerVersion = async () => { if (!syncConflict) return; const result = await loadRemoteProject(syncConflict.id); if (!result.ok) { toast("Couldn't load the other version. Check your connection and try again.", { tone: 'error' }); return; } saveProjectToStorage(result.data); setSyncBase(syncConflict.id, result.data.lastEdited); unsyncedStudies.remove(syncConflict.id); const loaded = loadProjectById(syncConflict.id); savedSignatureRef.current = { id: syncConflict.id, signature: signatureOf(loaded) }; setProject(loaded); setProjectIndex(loadProjectIndex()); setSyncConflict(null); setSyncStatus(''); toast('Switched to the version from your other device'); }; // Puts an earlier version of the open study back. What you just typed is saved first (so it is in the list too), // then the server swaps the versions, and this device takes the restored one. const restoreEarlierVersion = async (versionId) => { if (!project) return false; await flushPendingSave(); const result = await restoreStudyVersion(project.id, versionId); if (!result.ok) return false; window.clearTimeout(saveTimerRef.current); pendingSaveRef.current = null; const restored = result.data; try { saveProjectToStorage(restored); } catch { /* storage full: the account has it */ } unsyncedStudies.remove(restored.id); setSyncBase(restored.id, restored.lastEdited); savedSignatureRef.current = { id: restored.id, signature: signatureOf(restored) }; setSyncConflict(null); setProject(restored); setProjectIndex(loadProjectIndex()); toast('Earlier version put back. What you had before is still in the list.'); return true; }; // Third way out of a conflict: combine both copies chunk by chunk, then save the result as the new version. const mergeBothVersions = async () => { if (!project || !syncConflict) return; const result = await loadRemoteProject(syncConflict.id); if (!result.ok) { toast("Couldn't load the other version. Check your connection and try again.", { tone: 'error' }); return; } const { project: merged, stats } = mergeStudies(project, result.data); setSyncBase(syncConflict.id, result.data.lastEdited); savedSignatureRef.current = { id: merged.id, signature: signatureOf(merged) }; setProject(merged); const toSave = { ...merged, lastEdited: Date.now() }; try { saveProjectToStorage(toSave); } catch { /* storage full: the server copy below still holds it */ } unsyncedStudies.add(toSave.id); setProjectIndex(loadProjectIndex()); const saved = await pushProject(toSave); if (saved.ok) { toast(`Combined both versions: ${stats.added} chunk${stats.added === 1 ? '' : 's'} added from the other device, ${stats.merged} merged`); } }; const updateProject = (updater) => { setProject((current) => (current ? updater(current) : current)); }; const updateChunk = (chunkId, patch) => { updateProject((current) => ({ ...current, chapters: current.chapters.map((ch) => ({ ...ch, chunks: ch.chunks.map((c) => (c.id === chunkId ? { ...c, ...patch, editedAt: Date.now() } : c)), })), })); }; // Navigation const openBibleReader = () => setCurrentPage('reader'); const openReadingPlans = () => setCurrentPage('reading-plans'); const openPrayer = () => setCurrentPage('prayer'); // "Pray about this verse": opens the journal with the verse filled in const startPrayerFor = (label) => { setPrayerDraft({ label }); setCurrentPage('prayer'); }; // Jumps straight into a specific chapter and opens the reader (no plan context). const readReadingPlanDay = (bookAbbrev, chapter) => { reader.jumpToReaderVerse(bookAbbrev, chapter, 1); setCurrentPage('reader'); }; // Opens the Reader inside a plan: on `ref` (or the day's first unread chapter), // in the plan's own translation, with the plan strip and end-of-day card active. const openPlanReading = (progressId, dayIndex, ref) => { const progress = readingPlans.planProgress.find((p) => p.id === progressId); if (!progress) return; const schedule = getPlanSchedule(progress.planId); // A day with nothing scheduled would leave the Reader where it was — use the nearest real day. const day = schedule[dayIndex]?.length ? dayIndex : Math.max(0, nearestReadingDay(schedule, dayIndex)); const refs = schedule[day] ?? []; const done = progress.completedDays.includes(day); const read = new Set(done ? [] : progress.chaptersRead?.[day] ?? []); const target = ref ?? refs.find((r) => !read.has(`${r.book}.${r.chapter}`)) ?? refs[0]; setReaderPlan({ progressId, dayIndex: day }); if (target) { reader.setReaderBookAbbrev(target.book); reader.setReaderChapter(target.chapter); reader.setReaderSelectedVerse(null); reader.setReaderCrossRefs(null); } setCurrentPage('reader'); }; // Changing the translation while a plan is open changes that plan's translation. const setReaderTranslation = (id) => { if (readerPlan) readingPlans.setPlanTranslation(readerPlan.progressId, id); else reader.setReaderTranslation(id); }; // Starts book-mode audio playback at a specific plan day's chapter, then // shows the Home audio player. Uses handlePlayBookAudioFrom (not the reader // player) so this works even without opening the reader first. const listenReadingPlanDay = (bookAbbrev, chapter) => { audio.handlePlayBookAudioFrom(bookAbbrev, chapter); goStudies(); }; // Builds a new chunk-builder project seeded with a reading-plan day's // passage — one whole-chapter chunk per chapter in the day — and jumps // straight into the Study page, skipping the manual chapter/chunk setup. const studyReadingPlanDay = async (dayRefs, title) => { setStudyBuildBusy(true); try { const chapters = await Promise.all(dayRefs.map(async ({ book, chapter }) => { const data = await fetchStudyChapter('BSB', book, chapter); const verses = parseBibleChapter(data); const bookName = bookOptions.find((b) => b.abbrev === book)?.name ?? book; const chunk = { id: makeId(), startVerse: verses[0]?.number ?? 1, endVerse: verses.at(-1)?.number ?? 1, spilloverEndVerse: null, observation: '', interpretation: '', application: '', crossReferences: [], greekWords: [], generalNotes: '', episodeNumber: '', episodeTitle: '', finalScript: '', tags: [], }; return { book: bookName, bookAbbrev: book, chapter: String(chapter), verses, chunks: [chunk] }; })); const newProject = { id: makeId(), title: title || 'Reading Plan Study', translation: 'BSB', lastEdited: Date.now(), selectedChunkId: chapters[0]?.chunks[0]?.id ?? null, chapters, }; setProject(newProject); setCurrentPage('study'); } catch (err) { console.error('studyReadingPlanDay error:', err); toast(err?.message?.startsWith("You're offline") ? err.message : "Couldn't load today's passage. Please try again.", { tone: 'error' }); } finally { setStudyBuildBusy(false); } }; const openNewProject = () => { setProject(null); setCurrentPage('setup'); }; const openImportProject = () => { setProject(null); setCurrentPage('import'); }; // The My Studies page. const goStudies = () => { setProjectIndex(loadProjectIndex()); setCurrentPage('home'); setProject(null); }; // "Home": the page the user chose in Settings (My Studies, the Bible Reader, or Reading // Plans). If they're already on it, Home means My Studies, so there is always somewhere to go. const goHome = () => { const target = START_PAGES[reader.startPagePref] ?? 'home'; if (target === 'home' || target === currentPage) { goStudies(); return; } setProjectIndex(loadProjectIndex()); setProject(null); setCurrentPage(target); }; // Manual retry after a failed background sync (shown in the project header). const retrySync = async () => { if (!project) return; const toSave = { ...project, lastEdited: Date.now() }; try { saveProjectToStorage(toSave); } catch { /* storage full: still try the server */ } unsyncedStudies.add(toSave.id); await pushProject(toSave); }; // Shared with every page via useApp(). Page-specific state lives in the // pages themselves or in hooks under src/hooks/. const contextValue = { // Navigation currentPage, setCurrentPage, goHome, goStudies, authServerDown, // Auth (login/2FA/settings form state lives in AuthPage and SettingsPage) authUser, setAuthUser, signIn, // Save any pending bookmarks/highlights to the account before signing out logout: async () => { await reader.flushMarks(); await reader.flushPrefs(); await logout(); }, // Save / sync status (shown in the stage bar) saveStatus, setSaveStatus, syncStatus, retrySync, syncConflict, keepMyVersion, useServerVersion, mergeBothVersions, restoreEarlierVersion, // Home projectIndex, ...library, ...readingPlans, openBibleReader, openReadingPlans, readReadingPlanDay, listenReadingPlanDay, studyReadingPlanDay, studyBuildBusy, openImportProject, openNewProject, // ⌘K palette, shortcut list, and hand-offs to the Reader / Settings paletteOpen, openPalette: () => setPaletteOpen(true), readerRequest, clearReaderRequest: () => setReaderRequest(null), requestReaderSearch: (text) => setReaderRequest({ search: text }), settingsFocus, clearSettingsFocus: () => setSettingsFocus(null), refreshProjectIndex: () => setProjectIndex(loadProjectIndex()), openBackup: () => { setSettingsFocus('backup'); setCurrentPage('settings'); }, // Audio (book player on Home + chapter player in the Reader) ...audio, // Reader ...reader, readingHistory: readingHistory.readingHistory, ...prayerJournal, prayerDraft, clearPrayerDraft: () => setPrayerDraft(null), startPrayerFor, openPrayer, readerPlan, setReaderPlan, readerPlanActive: !!readerPlanProgress, openPlanReading, setReaderTranslation, // Setup (chunk-builder state lives in SetupPage via useChunkBuilder) project, setProject, allChunks, updateProject, // Study (editor state lives in StudyPage and its hooks) updateChunk, }; return ( }> {paletteOpen && setPaletteOpen(false)} />} {helpOpen && setHelpOpen(false)} />} ); }; // Shown while a page's code downloads: the app bar's outline and a few resting blocks in the // reading theme, so the screen doesn't flash a different colour before the page appears. function PageLoading() { const { readerTheme } = useApp(); return (