Build and Push Docker Image / build-and-push (push) Successful in 5m13s
- 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>
649 lines
30 KiB
React
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;
|