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 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 { 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 { loadRemoteProject, 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'; // 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')); // --------------------------------------------------------------------------- // 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' 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 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(); }, onSignOut: () => { reader.stopMarksSync(); reader.stopPrefsSync(); setProjectIndex(switchStorageUser(null)); goStudies(); }, }); const library = useProjectLibrary({ authUser, project, setProject, setProjectIndex, setCurrentPage, }); // ⌘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', }; 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. // 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 [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) { setSyncBase(toSave.id, result.data?.lastEdited ?? toSave.lastEdited); 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; }; useEffect(() => { if (!project) return undefined; 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; } if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current); saveTimerRef.current = window.setTimeout(async () => { savedSignatureRef.current = { id: project.id, signature }; // Stamp lastEdited once so localStorage and server receive the same value const toSave = { ...project, lastEdited: Date.now() }; // 1. Save locally saveProjectToStorage(toSave); 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); }, 1000); return () => { if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current); }; }, [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); 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'); }; 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 } : c)), })), })); }; // Navigation const openBibleReader = () => setCurrentPage('reader'); const openReadingPlans = () => setCurrentPage('reading-plans'); // 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 res = await fetch(`https://bible.helloao.org/api/BSB/${book}/${chapter}.json`); if (!res.ok) throw new Error(`Failed to load ${book} ${chapter}`); const data = await res.json(); 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("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() }; saveProjectToStorage(toSave); 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, // 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, 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 (