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:
1 parent
1edbd364ea
commit
d13f55faf7
65 files changed
+2947
-208
No files matched your search
+135
-27
@@ -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;
|
||||
Reference in new issue
Block a user