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 (
Loading…
Bible Study Project
{sharedError}
Loading shared study…
Loading…