diff --git a/public/sw.js b/public/sw.js index d4a6125..90cda9a 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const SHELL = 'bible-shell-v1'; +const SHELL = 'bible-shell-v2'; const API = 'bible-api-v1'; // Chapter text, interlinear cross-refs, and (BSB) chapter audio all come from // these origins — cache them so the reader, reading plans, and audio all keep @@ -27,8 +27,10 @@ self.addEventListener('fetch', (e) => { const { request } = e; const url = request.url; - // Never intercept API routes — let Express handle them - if (new URL(url).pathname.startsWith('/api/')) return; + // Never intercept this app's own API routes — let Express handle them. (Only same-origin ones: the + // Bible text at bible.helloao.org/api/... must pass through to the offline cache below.) + const { origin, pathname } = new URL(url); + if (origin === self.location.origin && pathname.startsWith('/api/')) return; // External Bible API (text, cross-refs, audio): network-first, fall back to cache. // Audio elements often issue Range requests, which come back as 206 Partial @@ -37,7 +39,11 @@ self.addEventListener('fetch', (e) => { e.respondWith( fetch(request) .then(res => { - if (res.status === 200) caches.open(API).then(c => c.put(request, res.clone())); + // Clone *now*: once the page starts reading the body a later clone() throws and nothing is saved. + if (res.status === 200) { + const copy = res.clone(); + e.waitUntil(caches.open(API).then(c => c.put(request, copy)).catch(() => {})); + } return res; }) .catch(() => @@ -56,7 +62,10 @@ self.addEventListener('fetch', (e) => { e.respondWith( fetch(request) .then(res => { - if (res.ok) caches.open(SHELL).then(c => c.put(request, res.clone())); + if (res.ok) { + const copy = res.clone(); + e.waitUntil(caches.open(SHELL).then(c => c.put(request, copy)).catch(() => {})); + } return res; }) .catch(() => @@ -75,7 +84,10 @@ self.addEventListener('fetch', (e) => { c.match(request).then(hit => { if (hit) return hit; return fetch(request).then(res => { - if (res.ok) c.put(request, res.clone()); + if (res.ok) { + const copy = res.clone(); + e.waitUntil(c.put(request, copy).catch(() => {})); + } return res; }); }) diff --git a/server/db.js b/server/db.js index 7dbf94e..32668bb 100644 --- a/server/db.js +++ b/server/db.js @@ -353,6 +353,12 @@ export function getProject(id, userId) { } } +/** The server copy's lastEdited for a project owned by userId, or null when there isn't one. */ +export function getProjectEdited(id, userId) { + const row = db.prepare('SELECT last_edited AS lastEdited FROM projects WHERE id = ? AND user_id = ?').get(id, userId); + return row ? row.lastEdited : null; +} + /** * Insert or replace a project owned by userId. * Returns the summary, or null if the id already belongs to a different user. diff --git a/server/index.js b/server/index.js index bbf22e2..ce6dee6 100644 --- a/server/index.js +++ b/server/index.js @@ -6,7 +6,7 @@ import { randomUUID } from 'crypto'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import { - initDb, getAllProjects, getProject, upsertProject, deleteProject, + initDb, getAllProjects, getProject, getProjectEdited, upsertProject, deleteProject, countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects, enableTotp, disableTotp, setBackupCodeHashes, setPodcastName, getShareToken, setShareToken, clearShareToken, getProjectByShareToken, @@ -26,6 +26,7 @@ import { startReminderScheduler, sendTestReminder, isValidTimeZone } from './rem import { registerBibleRoutes } from './bibleApi.js'; import { cleanMarks } from './readerMarks.js'; import { cleanPrefsPatch, mergePrefs } from './prefs.js'; +import { isSaveConflict, parseBase } from './projectSync.js'; // Shape returned to the client for the signed-in user (login, MFA verify, /auth/me). function userPayload(user) { @@ -398,6 +399,11 @@ app.put('/api/projects/:id', requireAuth, (req, res) => { } if (body.id.length > 100) return res.status(400).json({ error: 'Invalid project id.' }); if (body.title.length > 500) return res.status(400).json({ error: 'Project title is too long.' }); + // Refuse to overwrite a copy another device saved after the one this device last saw + const stored = getProjectEdited(body.id, req.session.userId); + if (isSaveConflict({ stored, base: parseBase(req.get('X-Base-Edited')), force: req.query.force === '1' })) { + return res.status(409).json({ error: 'This study was changed on another device.', code: 'conflict', serverEdited: stored }); + } const saved = upsertProject(body, req.session.userId); if (!saved) { return res.status(403).json({ error: 'That project belongs to a different account.' }); diff --git a/server/prefs.js b/server/prefs.js index dfe7f90..7d877aa 100644 --- a/server/prefs.js +++ b/server/prefs.js @@ -10,10 +10,20 @@ const ENUMS = { commentarySource: ['matthew-henry', 'john-gill', 'jamieson-fausset-brown', 'tyndale', 'adam-clarke', 'john-calvin', 'keil-delitzsch'], }; const DAY_MS = 86_400_000; +const COLOR = /^#[0-9a-fA-F]{3,8}$/; // A cleaned value, or undefined when it isn't acceptable for that preference. export function cleanPrefValue(key, value) { if (ENUMS[key]) return ENUMS[key].includes(value) ? value : undefined; + if (key === 'colorLabels') { + // What each highlight colour means to the reader: { "#fef08a": "Promises", ... } + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const out = {}; + for (const [color, label] of Object.entries(value).slice(0, 12)) { + if (COLOR.test(color) && typeof label === 'string' && label.trim()) out[color] = label.trim().slice(0, 30); + } + return out; + } if (key === 'position') { if (!value || typeof value !== 'object') return undefined; const book = String(value.book ?? '').toUpperCase(); diff --git a/server/projectSync.js b/server/projectSync.js new file mode 100644 index 0000000..3357680 --- /dev/null +++ b/server/projectSync.js @@ -0,0 +1,18 @@ +// Stops one device from silently overwriting a study another device has saved since. The browser sends +// the `lastEdited` of the server copy it last saw (X-Base-Edited); if the server's copy has moved past +// that, the save is refused with 409 and the person chooses whose version to keep. + +// stored: the server copy's lastEdited (null when there is none); base: what the client last saw +// (null/undefined when it never synced this study); force: the person chose "keep mine". +export function isSaveConflict({ stored, base, force = false }) { + if (force || stored == null) return false; + if (!Number.isFinite(base)) return false; // a device that never saw the server's copy has nothing to compare + return stored > base; +} + +// Reads the base header; anything that isn't a plain number is treated as "unknown". +export function parseBase(header) { + if (header == null || header === '') return null; + const n = Number(header); + return Number.isFinite(n) ? n : null; +} diff --git a/server/projectSync.test.js b/server/projectSync.test.js new file mode 100644 index 0000000..196a9e3 --- /dev/null +++ b/server/projectSync.test.js @@ -0,0 +1,24 @@ +import { expect, test } from 'vitest'; +import { isSaveConflict, parseBase } from './projectSync.js'; + +test('a save is fine when the server copy is no newer than what the device last saw', () => { + expect(isSaveConflict({ stored: 100, base: 100 })).toBe(false); + expect(isSaveConflict({ stored: 90, base: 100 })).toBe(false); +}); + +test('a save conflicts when another device saved after the one this device last saw', () => { + expect(isSaveConflict({ stored: 200, base: 100 })).toBe(true); +}); + +test('first saves, unknown bases and "keep mine" never conflict', () => { + expect(isSaveConflict({ stored: null, base: 100 })).toBe(false); + expect(isSaveConflict({ stored: 200, base: null })).toBe(false); + expect(isSaveConflict({ stored: 200, base: 100, force: true })).toBe(false); +}); + +test('reads the base header', () => { + expect(parseBase('1700000000000')).toBe(1700000000000); + expect(parseBase('')).toBeNull(); + expect(parseBase(undefined)).toBeNull(); + expect(parseBase('abc')).toBeNull(); +}); diff --git a/server/readerMarks.js b/server/readerMarks.js index 9666ccc..172c0e6 100644 --- a/server/readerMarks.js +++ b/server/readerMarks.js @@ -30,10 +30,13 @@ export function cleanMarks(body) { if (!int(h.startOffset, 0, 5000) || !int(h.endOffset, 1, 5000) || h.endOffset <= h.startOffset) continue; if (typeof h.color !== 'string' || !COLOR.test(h.color)) continue; seen.add(h.id); - highlights.push({ + const entry = { id: h.id, book: String(h.book).toUpperCase(), chapter: h.chapter, verse: h.verse, startOffset: h.startOffset, endOffset: h.endOffset, color: h.color, - }); + }; + // The highlighted words, kept so lists of highlights can show them without loading the chapter + if (typeof h.text === 'string' && h.text.length > 0) entry.text = h.text.slice(0, 500); + highlights.push(entry); } } return { bookmarks, highlights }; diff --git a/src/App.flow.test.jsx b/src/App.flow.test.jsx new file mode 100644 index 0000000..84feff8 --- /dev/null +++ b/src/App.flow.test.jsx @@ -0,0 +1,249 @@ +// How the app moves and recovers: the Back button, the ⌘K palette, Undo, sync conflicts, backups. +import { render, screen, waitFor, within, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { vi, beforeEach, afterEach, describe, test, expect } from 'vitest'; +import App from './App'; +import { resetLexiconCaches } from './utils/wordStudy.js'; +import { switchStorageUser } from './utils/bibleUtils.jsx'; +import { resetSyncBases } from './utils/syncBase.js'; + +const chapterData = { + chapter: { content: [ + { type: 'verse', number: 1, content: ['In the beginning God created.'] }, + { type: 'verse', number: 2, content: ['The earth was without form.'] }, + ] }, +}; +const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) }); + +// A fake server: signed in as `user`, a settable answer for saving studies, and a log of every call. +function stubServer({ user = null, putStatus = 200 } = {}) { + const calls = []; + const state = { putStatus }; + vi.stubGlobal('fetch', vi.fn((url, opts = {}) => { + const route = `${opts.method ?? 'GET'} ${url.split('?')[0]}`; + calls.push({ route, url, opts }); + if (url.includes('bible.helloao.org')) return json(200, chapterData); + if (!url.startsWith('/api/')) return Promise.resolve({ ok: false }); + if (route === 'GET /api/auth/me') return user ? json(200, user) : json(401, { error: 'Not signed in' }); + if (route === 'GET /api/projects') return json(200, []); + if (route === 'GET /api/reader/ink') return json(200, {}); + if (route.startsWith('PUT /api/projects/')) { + return state.putStatus === 409 + ? json(409, { error: 'This study was changed on another device.', code: 'conflict', serverEdited: 999 }) + : json(200, { id: url.split('/').pop().split('?')[0], title: 'x', lastEdited: 12345, chapterSummary: '' }); + } + return json(404, { error: 'not mocked' }); + })); + return { calls, state }; +} + +beforeEach(() => { + localStorage.clear(); + resetLexiconCaches(); + resetSyncBases(); + switchStorageUser(null); + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ ok: false }))); + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock'); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); +}); +afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); + +async function renderApp() { + render(); + await waitFor(() => expect(screen.queryByText('Loading…')).not.toBeInTheDocument()); +} + +function seedProject({ id = 'p1', title = 'Titus Notes', observation = 'Grace abounds to all.' } = {}) { + const project = { + id, title, translation: 'BSB', lastEdited: Date.now(), selectedChunkId: null, + chapters: [{ + book: 'Titus', bookAbbrev: 'TIT', chapter: '1', verses: [{ number: 1, text: 'Paul, a servant of God.' }], + chunks: [{ id: 'c1', startVerse: 1, endVerse: 1, observation, interpretation: '', application: '', crossReferences: [], greekWords: [], tags: [] }], + }], + }; + const index = JSON.parse(localStorage.getItem('bible-study-index') || '[]'); + index.push({ id, title, lastEdited: project.lastEdited, chapterSummary: 'Titus 1', tags: [] }); + localStorage.setItem(`bible-study-project-${id}`, JSON.stringify(project)); + localStorage.setItem('bible-study-index', JSON.stringify(index)); +} + +describe('Back button', () => { + test('moves back through the pages you visited instead of leaving the app', async () => { + const user = userEvent.setup(); + await renderApp(); + expect(await screen.findByRole('heading', { name: 'My Studies' })).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /reading plans/i })); + expect(await screen.findByRole('heading', { name: /reading plans/i })).toBeInTheDocument(); + window.history.back(); + expect(await screen.findByRole('heading', { name: 'My Studies' })).toBeInTheDocument(); + window.history.forward(); + expect(await screen.findByRole('heading', { name: /reading plans/i })).toBeInTheDocument(); + }); +}); + +const paletteBox = async () => within(await screen.findByRole('dialog', { name: /search and go to/i })).getByRole('combobox'); + +describe('Search palette (⌘K)', () => { + test('a typed passage goes straight to the Bible reader', async () => { + const user = userEvent.setup(); + await renderApp(); + await user.keyboard('{Control>}k{/Control}'); + await user.type(await paletteBox(), 'john 3:16'); + expect(await screen.findByRole('option', { name: /John 3:16/ })).toBeInTheDocument(); + await user.keyboard('{Enter}'); + expect(await screen.findByRole('heading', { name: /John 3/ })).toBeInTheDocument(); + expect(screen.queryByRole('dialog', { name: /search and go to/i })).not.toBeInTheDocument(); + }); + + test('finds a note you wrote and opens that study', async () => { + seedProject({ observation: 'Grace abounds to all.' }); + const user = userEvent.setup(); + await renderApp(); + await user.keyboard('{Control>}k{/Control}'); + await user.type(await paletteBox(), 'abounds'); + const hit = await screen.findByRole('option', { name: /Titus 1:1/ }); + await user.click(hit); + expect(await screen.findByText(/Your notes/i)).toBeInTheDocument(); + }); + + test('Escape closes it', async () => { + const user = userEvent.setup(); + await renderApp(); + await user.keyboard('{Control>}k{/Control}'); + await paletteBox(); + await user.keyboard('{Escape}'); + expect(screen.queryByRole('dialog', { name: /search and go to/i })).not.toBeInTheDocument(); + }); + + test('? lists the keyboard shortcuts', async () => { + const user = userEvent.setup(); + await renderApp(); + await user.keyboard('?'); + expect(await screen.findByRole('dialog', { name: /keyboard shortcuts/i })).toBeInTheDocument(); + }); +}); + +describe('Undo', () => { + test('deleting a project, then Undo, keeps its notes', async () => { + seedProject(); + const user = userEvent.setup(); + await renderApp(); + await user.click(screen.getByRole('button', { name: /^delete$/i })); + expect(await screen.findByRole('status')).toHaveTextContent(/deleted/i); + await user.click(screen.getByRole('button', { name: /undo/i })); + expect(await screen.findByText('Titus Notes')).toBeInTheDocument(); + expect(JSON.parse(localStorage.getItem('bible-study-project-p1')).chapters[0].chunks[0].observation).toBe('Grace abounds to all.'); + }); +}); + +describe('Sync conflict', () => { + const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: '', isAdmin: false }; + + async function startStudy(u) { + await renderApp(); + await u.click((await screen.findAllByRole('button', { name: /new project/i }))[0]); + await u.click(await screen.findByRole('button', { name: /load chapter/i })); + await screen.findByText(/Scripture & Chunks/i); + } + + test('a save the server refuses shows the choice instead of overwriting', async () => { + const server = stubServer({ user, putStatus: 409 }); + const u = userEvent.setup(); + await startStudy(u); + const banner = await screen.findByRole('alert', {}, { timeout: 4000 }); + expect(banner).toHaveTextContent(/changed on another device/i); + expect(within(banner).getByRole('button', { name: /keep this version/i })).toBeInTheDocument(); + expect(within(banner).getByRole('button', { name: /use the other version/i })).toBeInTheDocument(); + + // "Keep this version" sends the save again, this time as an overwrite + server.state.putStatus = 200; + await u.click(within(banner).getByRole('button', { name: /keep this version/i })); + await waitFor(() => expect(server.calls.some((c) => c.url.includes('?force=1'))).toBe(true)); + await waitFor(() => expect(screen.queryByText(/changed on another device/i)).not.toBeInTheDocument()); + }, 15000); + + test('opening a study without editing it does not touch the server', async () => { + seedProject(); + const server = stubServer({ user }); + const u = userEvent.setup(); + await renderApp(); + // signed in: the namespaced storage starts empty, so seed under the user's keys by resuming nothing + expect(server.calls.filter((c) => c.route.startsWith('PUT')).length).toBe(0); + await u.click(await screen.findByRole('button', { name: /read bible/i })); + expect(server.calls.filter((c) => c.route.startsWith('PUT /api/projects')).length).toBe(0); + }); +}); + +describe('Outline stage', () => { + async function openOutline() { + // local-only mode (the account server is unreachable), with chapter text available + vi.stubGlobal('fetch', vi.fn((url) => (url.includes('bible.helloao.org') ? json(200, chapterData) : Promise.resolve({ ok: false })))); + const user = userEvent.setup(); + await renderApp(); + await user.click((await screen.findAllByRole('button', { name: /new project/i }))[0]); + await user.click(await screen.findByRole('button', { name: /load chapter/i })); + await screen.findByText(/Scripture & Chunks/i); + // one chunk covering verses 1-2 + const verses = within(screen.getByTestId('verse-list')); + fireEvent.click(verses.getByRole('button', { name: /In the beginning/ })); + fireEvent.click(verses.getByRole('button', { name: /without form/ }), { shiftKey: true }); + await screen.findByText('1 created'); + await user.click(screen.getByRole('button', { name: /^outline$/i })); + await screen.findByRole('heading', { name: 'Outline' }); + return user; + } + + test('starts empty, and "Start from my chunks" makes a main point for each chunk', async () => { + const user = await openOutline(); + expect(screen.getByText(/no outline yet/i)).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /start from my chunks/i })); + const list = await screen.findByRole('list', { name: 'Outline' }); + expect(within(list).getAllByRole('textbox')).toHaveLength(1); + expect(within(list).getByText(/Open chunk in Study/i)).toBeInTheDocument(); + }); + + test('Enter adds a point, Tab makes it a sub-point, and they are numbered I. and A.', async () => { + const user = await openOutline(); + await user.click(screen.getByRole('button', { name: /start blank/i })); + const first = await screen.findByRole('textbox', { name: 'Point I.' }); + await user.type(first, 'Creation{Enter}'); + const second = await screen.findByRole('textbox', { name: 'Point II.' }); + await user.type(second, 'Light{Tab}'); + expect(await screen.findByRole('textbox', { name: 'Point A.' })).toHaveValue('Light'); + expect(screen.getByRole('textbox', { name: 'Point I.' })).toHaveValue('Creation'); + }); + + test('deleting a point can be undone', async () => { + const user = await openOutline(); + await user.click(screen.getByRole('button', { name: /start blank/i })); + await user.type(await screen.findByRole('textbox', { name: 'Point I.' }), 'Keep me'); + await user.click(screen.getByRole('button', { name: /delete i\./i })); + expect(screen.queryByRole('textbox', { name: 'Point I.' })).not.toBeInTheDocument(); + await user.click(await screen.findByRole('button', { name: /undo/i })); + expect(await screen.findByRole('textbox', { name: 'Point I.' })).toHaveValue('Keep me'); + }); +}); + +describe('Study cards on Home', () => { + test('a long run of chapters and many tags stay short', async () => { + const chapters = Array.from({ length: 24 }, (_, i) => `Luke ${i + 1}`).join(', '); + const tags = ['A', 'B', 'C', 'D', 'E', 'F']; + localStorage.setItem('bible-study-index', JSON.stringify([{ id: 'p', title: 'Luke', lastEdited: Date.now(), chapterSummary: chapters, tags }])); + await renderApp(); + expect(await screen.findByText('Luke 1\u201324')).toBeInTheDocument(); + expect(screen.getByText('+3 more')).toBeInTheDocument(); + expect(screen.queryByText('F', { selector: 'span' })).not.toBeInTheDocument(); // only in the Tag filter + }); +}); + +describe('Window title', () => { + test('names the page you are on, including the chapter in the reader', async () => { + const user = userEvent.setup(); + await renderApp(); + expect(document.title).toBe('My Studies · Bible Study'); + await user.click(screen.getByRole('button', { name: /reading plans/i })); + await waitFor(() => expect(document.title).toBe('Reading Plans · Bible Study')); + await user.click(screen.getByRole('button', { name: /read bible/i })); + await waitFor(() => expect(document.title).toMatch(/^[A-Za-z0-9 ]+ \d+ · Bible Study$/)); + }); +}); diff --git a/src/App.jsx b/src/App.jsx index af78e66..50d715d 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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,49 @@ 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); + }, []); + + // 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', + }; + 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'), + onArrive: (page) => { if (page === 'home') setProjectIndex(loadProjectIndex()); }, + }); + // Startup: migrate old keys and load index useEffect(() => { migrateOldStorageKeys(); @@ -109,10 +162,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 +210,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 +335,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 +371,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 +393,7 @@ const App = () => { saveStatus, setSaveStatus, syncStatus, retrySync, + syncConflict, keepMyVersion, useServerVersion, // Home projectIndex, ...library, @@ -303,6 +406,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 +434,26 @@ const App = () => { }> + {paletteOpen && setPaletteOpen(false)} />} + {helpOpen && setHelpOpen(false)} />} ); }; +// 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 ( -
-

Loading…

+
+
+