Files
study-bible-project/src/App.jsx
T
nmemmertandClaude Sonnet 5.5 760c1e568b
Build and Push Docker Image / build-and-push (push) Failing after 19s
Sync bookmarks, highlights and display preferences to the account
Bookmarks and text highlights are saved per user and merged across devices
(union on first contact, newer server copy when this device is clean). Theme,
default translation, verse layout, commentary source and reading position sync
too, each with its own timestamp so the newest change to each setting wins.
The Reader never moves under you while it is open. Signing out clears this
device's marks and forgets sync timestamps.

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

371 lines
14 KiB
React

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 (
<AppContext.Provider value={contextValue}>
<Suspense fallback={<PageLoading />}>
<AppRouter />
</Suspense>
</AppContext.Provider>
);
};
function PageLoading() {
return (
<div className="flex min-h-screen items-center justify-center bg-slate-900">
<p className="text-sm text-slate-400">Loading…</p>
</div>
);
}
function AppRouter() {
const { currentPage, authUser, authServerDown } = useApp();
const { token: sharedViewToken, project: sharedProject, error: sharedError } = useSharedView();
if (sharedViewToken) {
if (sharedError) {
return (
<div className="flex min-h-screen 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-screen items-center justify-center bg-slate-900">
<p className="text-sm text-slate-400">Loading shared study…</p>
</div>
);
}
return (
<div className="min-h-screen 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(100vh-2.5rem)] w-full border-0"
/>
</div>
);
}
if (authUser === undefined && !authServerDown) {
return (
<div className="flex min-h-screen items-center justify-center bg-slate-900">
<p className="text-sm text-slate-400">Loading…</p>
</div>
);
}
if (authUser === null && !authServerDown) return <AuthPage />;
if (currentPage === 'admin') return <AdminPage />;
if (currentPage === 'settings') return <SettingsPage />;
if (currentPage === 'reading-plans') return <ReadingPlansPage />;
if (currentPage === 'reader') return <ReaderPage />;
if (currentPage === 'import') return <ImportPage />;
if (currentPage === 'study') return <StudyPage />;
if (currentPage === 'script') return <ScriptPage />;
if (currentPage === 'export') return <ExportPage />;
if (currentPage === 'setup') return <SetupPage />;
if (currentPage === 'pronunciation') return <PronunciationPage />;
return <HomePage />;
}
export default App;