Files
study-bible-project/src/App.jsx
T
nmemmertandClaude Sonnet 5.5 207bfcb919
Build and Push Docker Image / build-and-push (push) Successful in 5m13s
Version history, notes on highlights and bookmarks, "you've studied this", prayer journal
- Earlier versions of a study: the server keeps compressed copies (about one per ten minutes of
  editing, the latest 30); Export shows what differs and puts one back (the replaced copy is kept)
- Notes on highlights and bookmarks: synced, merged without loss, in exports and backups
- Reader verse panel lists your studies and chunks that cover the verse, and opens them
- Prayer journal: requests, who for, linked verses, days prayed, answers; synced, in backups
  and the account export; daily prayer reminder through ntfy
- TODO.md lists the ideas not built yet

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 10:45:32 -04:00

649 lines
30 KiB
React

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 (
<AppContext.Provider value={contextValue}>
<Suspense fallback={<PageLoading />}>
<AppRouter />
</Suspense>
{paletteOpen && <CommandPalette onClose={() => setPaletteOpen(false)} />}
{helpOpen && <ShortcutsHelp onClose={() => setHelpOpen(false)} />}
<Tooltips theme={reader.readerTheme} />
</AppContext.Provider>
);
};
// 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 (
<div className="min-h-dvh bg-[var(--r-bg)]" style={themeVars(readerTheme)} role="status" aria-label="Loading">
<div className="safe-top border-b border-[var(--r-line)] bg-[var(--r-bar)]"><div className="h-12" /></div>
<div className="mx-auto max-w-3xl animate-pulse motion-reduce:animate-none space-y-3 px-5 pt-10" aria-hidden="true">
<div className="mx-auto h-7 w-1/3 rounded bg-[var(--r-line)]" />
<div className="h-4 rounded bg-[var(--r-line)]" />
<div className="h-4 w-11/12 rounded bg-[var(--r-line)]" />
<div className="h-4 w-10/12 rounded bg-[var(--r-line)]" />
</div>
<span className="sr-only">Loading…</span>
</div>
);
}
function AppRouter() {
const { currentPage, authUser, authServerDown } = useApp();
const { token: sharedViewToken, project: sharedProject, error: sharedError, needsPasscode, passcodeError, checking, submitPasscode } = useSharedView();
const sharedOutlineToken = new URLSearchParams(window.location.search).get('outline');
// A read-only outline link: no sign-in, nothing editable
if (sharedOutlineToken) return <SharedOutlineView token={sharedOutlineToken} />;
if (sharedViewToken) {
if (needsPasscode) return <SharedPasscodeForm dark error={passcodeError} busy={checking} onSubmit={submitPasscode} />;
if (sharedError) {
return (
<div className="flex min-h-dvh items-center justify-center bg-slate-900 px-4">
<div className="w-full max-w-sm rounded-3xl border border-white/10 bg-white p-8 text-center shadow-panel">
<p className="text-sm uppercase tracking-[0.24em] text-slate-400">Bible Study Project</p>
<p className="mt-4 text-sm text-rose-600">{sharedError}</p>
</div>
</div>
);
}
if (!sharedProject) {
return (
<div className="flex min-h-dvh items-center justify-center bg-slate-900">
<p className="text-sm text-slate-400">Loading shared study…</p>
</div>
);
}
return (
<div className="min-h-dvh bg-slate-100">
<div className="border-b border-slate-200 bg-slate-900 px-4 py-3 text-center text-xs text-slate-300">
🔗 Read-only shared view —{' '}
<a href="/" className="underline hover:text-white">go to Bible Study Project</a>
</div>
<iframe
title="Shared study"
srcDoc={buildExportHtml(sharedProject)}
sandbox="allow-popups"
className="h-[calc(100dvh-2.5rem)] w-full border-0"
/>
</div>
);
}
if (authUser === undefined && !authServerDown) return <PageLoading />;
if (authUser === null && !authServerDown) return <AuthPage />;
if (currentPage === 'admin') return <AdminPage />;
if (currentPage === 'settings') return <SettingsPage />;
if (currentPage === 'reading-plans') return <ReadingPlansPage />;
if (currentPage === 'prayer') return <PrayerPage />;
if (currentPage === 'reader') return <ReaderPage />;
if (currentPage === 'import') return <ImportPage />;
if (currentPage === 'study') return <StudyPage />;
if (currentPage === 'outline') return <OutlinePage />;
if (currentPage === 'script') return <ScriptPage />;
if (currentPage === 'export') return <ExportPage />;
if (currentPage === 'setup') return <SetupPage />;
if (currentPage === 'pronunciation') return <PronunciationPage />;
return <HomePage />;
}
// The toast provider sits outside so every page (and App itself) can show toasts.
const App = () => (
<ToastProvider>
<AppInner />
</ToastProvider>
);
export default App;