import { lazy, Suspense, useEffect, useRef, useState } from 'react'; import { getPlanSchedule, nearestReadingDay } from './utils/readingPlans.js'; import { AppContext, useApp } from './context/AppContext.js'; 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 { useReadingPlans } from './hooks/useReadingPlans.js'; import { useSharedView } from './hooks/useSharedView.js'; import HomePage from './pages/HomePage.jsx'; import { saveRemoteProject } from './syncService.js'; import { bookOptions, buildExportHtml, loadProjectIndex, makeId, parseBibleChapter, switchStorageUser, } from './utils/bibleUtils.jsx'; import { 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 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 // --------------------------------------------------------------------------- const App = () => { const [project, setProject] = useState(null); // 'home' | 'setup' | 'study' | 'script' | 'export' | 'reader' | 'import' | 'pronunciation' | 'settings' | 'admin' | 'reading-plans' const [currentPage, setCurrentPage] = useState('home'); 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]); 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)); goHome(); }, }); const library = useProjectLibrary({ authUser, project, setProject, setProjectIndex, setCurrentPage, }); // 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. useEffect(() => { if (!project) return; if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current); saveTimerRef.current = window.setTimeout(async () => { // 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 setSyncStatus('syncing'); const result = await saveRemoteProject(toSave); setSyncStatus(result.ok ? 'synced' : 'error'); // On success clear after 2.5s; on error keep visible until next successful sync if (result.ok) window.setTimeout(() => setSyncStatus(''), 2500); }, 1000); return () => { if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current); }; }, [project]); 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); goHome(); }; // 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); window.alert("Couldn't load today's passage. Please try again."); } finally { setStudyBuildBusy(false); } }; const openNewProject = () => { setProject(null); setCurrentPage('setup'); }; const openImportProject = () => { setProject(null); setCurrentPage('import'); }; const goHome = () => { setProjectIndex(loadProjectIndex()); setCurrentPage('home'); setProject(null); }; // Manual retry after a failed background sync (shown in the project header). const retrySync = async () => { if (!project) return; setSyncStatus('syncing'); const toSave = { ...project, lastEdited: Date.now() }; saveProjectToStorage(toSave); const result = await saveRemoteProject(toSave); setSyncStatus(result.ok ? 'synced' : 'error'); if (result.ok) window.setTimeout(() => setSyncStatus(''), 2500); }; // 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, 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, // Home projectIndex, ...library, ...readingPlans, openBibleReader, openReadingPlans, readReadingPlanDay, listenReadingPlanDay, studyReadingPlanDay, studyBuildBusy, openImportProject, openNewProject, // 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 ( }> ); }; function PageLoading() { return (

Loading…

); } function AppRouter() { const { currentPage, authUser, authServerDown } = useApp(); const { token: sharedViewToken, project: sharedProject, error: sharedError } = useSharedView(); if (sharedViewToken) { if (sharedError) { return (

Bible Study Project

{sharedError}

); } if (!sharedProject) { return (

Loading shared study…

); } return (
🔗 Read-only shared view —{' '} go to Bible Study Project