Add Outline stage, parallel reading, offline saves, backup, sync-conflict handling and more

Study
- New Outline stage (hierarchical I./A./1. points, linked to chunks, in all exports);
  the chunk stage is now "Chunks"
- Home cards: passages shown as ranges, tags capped with "+N more"

Reader
- Side-by-side translations and a Compare tab per verse
- Typeface, line spacing, verse-number settings; text and column scale with the screen
- Highlights/bookmarks sheet with named colours (synced); BSB footnote spacing fix
- Cmd-K palette (passages, studies, notes, marks), keyboard shortcuts and "?" help

Flow and safety
- Toasts with Undo replace most confirm()/alert()
- Back/Forward move between pages
- Server refuses saves over a newer copy (409); keep-mine / use-other banner;
  opening a study no longer re-stamps it
- Latest chapter request wins when flipping quickly

Offline and data
- Save books, plan days or a whole BSB/NET for offline
- Service worker fixes: helloao /api/ URLs were skipped and responses cloned too late
- Settings: download/restore a full backup

Accessibility
- Sheets trap focus and close on Escape, skip link, focus ring, reduced motion,
  theme contrast raised to 4.5:1 (with a test)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-01 14:08:08 -04:00
1 parent 1edbd364ea
commit d13f55faf7
65 files changed
+2947 -208

No files matched your search

+135 -27
View File
@@ -2,18 +2,24 @@ 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 { 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 { useReadingPlans } from './hooks/useReadingPlans.js';
import { useSharedView } from './hooks/useSharedView.js';
import HomePage from './pages/HomePage.jsx';
import { saveRemoteProject } from './syncService.js';
import { themeVars } from './components/reader/readerThemes.js';
import { loadRemoteProject, saveRemoteProject } from './syncService.js';
import { getSyncBase, setSyncBase } from './utils/syncBase.js';
import {
bookOptions, buildExportHtml, loadProjectIndex, makeId, parseBibleChapter, switchStorageUser,
} from './utils/bibleUtils.jsx';
import { migrateOldStorageKeys, saveProjectToStorage } from './utils/projectStorage.js';
import { loadProjectById, 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.
@@ -24,6 +30,7 @@ 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'));
@@ -42,10 +49,13 @@ function savedStartPage() {
try { return START_PAGES[localStorage.getItem('start-page')] ?? 'home'; } catch { return 'home'; }
}
const App = () => {
const AppInner = () => {
const { toast, setTheme: setToastTheme } = useToast();
const [project, setProject] = useState(null);
// 'home' | 'setup' | 'study' | 'script' | 'export' | 'reader' | 'import' | 'pronunciation' | 'settings' | 'admin' | 'reading-plans'
// 'home' | 'setup' | 'study' | 'outline' | 'script' | 'export' | 'reader' | 'import' | 'pronunciation' | 'settings' | 'admin' | 'reading-plans'
const [currentPage, setCurrentPage] = useState(savedStartPage);
const currentPageRef = useRef(currentPage);
currentPageRef.current = currentPage;
const [projectIndex, setProjectIndex] = useState([]);
const [saveStatus, setSaveStatus] = useState('');
const saveTimerRef = useRef(null);
@@ -67,7 +77,7 @@ const App = () => {
if (scroller) scroller.scrollTop = 0;
}, [currentPage]);
// Keep the browser/OS chrome (status bar area, overscroll, scrollbars) in the chosen theme
useEffect(() => { applySystemChrome(reader.readerTheme); }, [reader.readerTheme]);
useEffect(() => { applySystemChrome(reader.readerTheme); setToastTheme(reader.readerTheme); }, [reader.readerTheme, setToastTheme]);
const { authUser, setAuthUser, authServerDown, signIn, logout } = useAuth({
onSignIn: (user) => {
setProjectIndex(switchStorageUser(user.id));
@@ -100,6 +110,33 @@ const App = () => {
setCurrentPage,
});
// ⌘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);
}, []);
// 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'),
onArrive: (page) => { if (page === 'home') setProjectIndex(loadProjectIndex()); },
});
// Startup: migrate old keys and load index
useEffect(() => {
migrateOldStorageKeys();
@@ -109,10 +146,45 @@ const App = () => {
const allChunks = project ? project.chapters.flatMap((ch) => ch.chunks) : [];
// Autosave: 1s after the last edit, save locally, then sync to the server.
// 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 [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) {
setSyncBase(toSave.id, result.data?.lastEdited ?? toSave.lastEdited);
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;
};
useEffect(() => {
if (!project) return;
if (!project) return undefined;
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;
}
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
saveTimerRef.current = window.setTimeout(async () => {
savedSignatureRef.current = { id: project.id, signature };
// Stamp lastEdited once so localStorage and server receive the same value
const toSave = { ...project, lastEdited: Date.now() };
@@ -122,17 +194,34 @@ const App = () => {
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);
// 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);
}, 1000);
return () => {
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
};
}, [project]);
}, [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);
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');
};
const updateProject = (updater) => {
setProject((current) => (current ? updater(current) : current));
@@ -230,7 +319,7 @@ const App = () => {
setCurrentPage('study');
} catch (err) {
console.error('studyReadingPlanDay error:', err);
window.alert("Couldn't load today's passage. Please try again.");
toast("Couldn't load today's passage. Please try again.", { tone: 'error' });
} finally {
setStudyBuildBusy(false);
}
@@ -266,12 +355,9 @@ const App = () => {
// 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);
await pushProject(toSave);
};
// Shared with every page via useApp(). Page-specific state lives in the
@@ -291,6 +377,7 @@ const App = () => {
saveStatus, setSaveStatus,
syncStatus,
retrySync,
syncConflict, keepMyVersion, useServerVersion,
// Home
projectIndex,
...library,
@@ -303,6 +390,13 @@ const App = () => {
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
@@ -324,14 +418,26 @@ const App = () => {
<Suspense fallback={<PageLoading />}>
<AppRouter />
</Suspense>
{paletteOpen && <CommandPalette onClose={() => setPaletteOpen(false)} />}
{helpOpen && <ShortcutsHelp onClose={() => setHelpOpen(false)} />}
</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="flex min-h-dvh items-center justify-center bg-slate-900">
<p className="text-sm text-slate-400">Loading…</p>
<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>
);
}
@@ -374,13 +480,7 @@ function AppRouter() {
);
}
if (authUser === undefined && !authServerDown) {
return (
<div className="flex min-h-dvh items-center justify-center bg-slate-900">
<p className="text-sm text-slate-400">Loading…</p>
</div>
);
}
if (authUser === undefined && !authServerDown) return <PageLoading />;
if (authUser === null && !authServerDown) return <AuthPage />;
@@ -390,6 +490,7 @@ function AppRouter() {
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 />;
@@ -397,4 +498,11 @@ function AppRouter() {
return <HomePage />;
}
// The toast provider sits outside so every page (and App itself) can show toasts.
const App = () => (
<ToastProvider>
<AppInner />
</ToastProvider>
);
export default App;