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

+18 -6
View File
@@ -1,4 +1,4 @@
const SHELL = 'bible-shell-v1'; const SHELL = 'bible-shell-v2';
const API = 'bible-api-v1'; const API = 'bible-api-v1';
// Chapter text, interlinear cross-refs, and (BSB) chapter audio all come from // 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 // 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 { request } = e;
const url = request.url; const url = request.url;
// Never intercept API routes — let Express handle them // Never intercept this app's own API routes — let Express handle them. (Only same-origin ones: the
if (new URL(url).pathname.startsWith('/api/')) return; // 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. // 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 // Audio elements often issue Range requests, which come back as 206 Partial
@@ -37,7 +39,11 @@ self.addEventListener('fetch', (e) => {
e.respondWith( e.respondWith(
fetch(request) fetch(request)
.then(res => { .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; return res;
}) })
.catch(() => .catch(() =>
@@ -56,7 +62,10 @@ self.addEventListener('fetch', (e) => {
e.respondWith( e.respondWith(
fetch(request) fetch(request)
.then(res => { .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; return res;
}) })
.catch(() => .catch(() =>
@@ -75,7 +84,10 @@ self.addEventListener('fetch', (e) => {
c.match(request).then(hit => { c.match(request).then(hit => {
if (hit) return hit; if (hit) return hit;
return fetch(request).then(res => { 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; return res;
}); });
}) })
+6
View File
@@ -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. * Insert or replace a project owned by userId.
* Returns the summary, or null if the id already belongs to a different user. * Returns the summary, or null if the id already belongs to a different user.
+7 -1
View File
@@ -6,7 +6,7 @@ import { randomUUID } from 'crypto';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { dirname, join } from 'path'; import { dirname, join } from 'path';
import { import {
initDb, getAllProjects, getProject, upsertProject, deleteProject, initDb, getAllProjects, getProject, getProjectEdited, upsertProject, deleteProject,
countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects, countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects,
enableTotp, disableTotp, setBackupCodeHashes, setPodcastName, enableTotp, disableTotp, setBackupCodeHashes, setPodcastName,
getShareToken, setShareToken, clearShareToken, getProjectByShareToken, getShareToken, setShareToken, clearShareToken, getProjectByShareToken,
@@ -26,6 +26,7 @@ import { startReminderScheduler, sendTestReminder, isValidTimeZone } from './rem
import { registerBibleRoutes } from './bibleApi.js'; import { registerBibleRoutes } from './bibleApi.js';
import { cleanMarks } from './readerMarks.js'; import { cleanMarks } from './readerMarks.js';
import { cleanPrefsPatch, mergePrefs } from './prefs.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). // Shape returned to the client for the signed-in user (login, MFA verify, /auth/me).
function userPayload(user) { 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.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.' }); 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); const saved = upsertProject(body, req.session.userId);
if (!saved) { if (!saved) {
return res.status(403).json({ error: 'That project belongs to a different account.' }); return res.status(403).json({ error: 'That project belongs to a different account.' });
+10
View File
@@ -10,10 +10,20 @@ const ENUMS = {
commentarySource: ['matthew-henry', 'john-gill', 'jamieson-fausset-brown', 'tyndale', 'adam-clarke', 'john-calvin', 'keil-delitzsch'], commentarySource: ['matthew-henry', 'john-gill', 'jamieson-fausset-brown', 'tyndale', 'adam-clarke', 'john-calvin', 'keil-delitzsch'],
}; };
const DAY_MS = 86_400_000; 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. // A cleaned value, or undefined when it isn't acceptable for that preference.
export function cleanPrefValue(key, value) { export function cleanPrefValue(key, value) {
if (ENUMS[key]) return ENUMS[key].includes(value) ? value : undefined; 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 (key === 'position') {
if (!value || typeof value !== 'object') return undefined; if (!value || typeof value !== 'object') return undefined;
const book = String(value.book ?? '').toUpperCase(); const book = String(value.book ?? '').toUpperCase();
+18
View File
@@ -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;
}
+24
View File
@@ -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();
});
+5 -2
View File
@@ -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 (!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; if (typeof h.color !== 'string' || !COLOR.test(h.color)) continue;
seen.add(h.id); seen.add(h.id);
highlights.push({ const entry = {
id: h.id, book: String(h.book).toUpperCase(), chapter: h.chapter, verse: h.verse, id: h.id, book: String(h.book).toUpperCase(), chapter: h.chapter, verse: h.verse,
startOffset: h.startOffset, endOffset: h.endOffset, color: h.color, 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 }; return { bookmarks, highlights };
+237
View File
@@ -0,0 +1,237 @@
// 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(<App />);
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
});
});
+135 -27
View File
@@ -2,18 +2,24 @@ import { lazy, Suspense, useEffect, useRef, useState } from 'react';
import { getPlanSchedule, nearestReadingDay } from './utils/readingPlans.js'; import { getPlanSchedule, nearestReadingDay } from './utils/readingPlans.js';
import { applySystemChrome } from './utils/systemChrome.js'; import { applySystemChrome } from './utils/systemChrome.js';
import { AppContext, useApp } from './context/AppContext.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 { useAudio } from './hooks/useAudio.js';
import { useAuth } from './hooks/useAuth.js'; import { useAuth } from './hooks/useAuth.js';
import { useBibleReader } from './hooks/useBibleReader.js'; import { useBibleReader } from './hooks/useBibleReader.js';
import { useProjectLibrary } from './hooks/useProjectLibrary.js'; import { useProjectLibrary } from './hooks/useProjectLibrary.js';
import { useHistoryNav } from './hooks/useHistoryNav.js';
import { useReadingPlans } from './hooks/useReadingPlans.js'; import { useReadingPlans } from './hooks/useReadingPlans.js';
import { useSharedView } from './hooks/useSharedView.js'; import { useSharedView } from './hooks/useSharedView.js';
import HomePage from './pages/HomePage.jsx'; 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 { import {
bookOptions, buildExportHtml, loadProjectIndex, makeId, parseBibleChapter, switchStorageUser, bookOptions, buildExportHtml, loadProjectIndex, makeId, parseBibleChapter, switchStorageUser,
} from './utils/bibleUtils.jsx'; } 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 // Less-common pages are split into their own chunks so the first load only
// downloads what's needed to show the home screen. // 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 SettingsPage = lazy(() => import('./pages/SettingsPage.jsx'));
const SetupPage = lazy(() => import('./pages/SetupPage.jsx')); const SetupPage = lazy(() => import('./pages/SetupPage.jsx'));
const StudyPage = lazy(() => import('./pages/StudyPage.jsx')); const StudyPage = lazy(() => import('./pages/StudyPage.jsx'));
const OutlinePage = lazy(() => import('./pages/OutlinePage.jsx'));
const ScriptPage = lazy(() => import('./pages/ScriptPage.jsx')); const ScriptPage = lazy(() => import('./pages/ScriptPage.jsx'));
const ExportPage = lazy(() => import('./pages/ExportPage.jsx')); const ExportPage = lazy(() => import('./pages/ExportPage.jsx'));
const PronunciationPage = lazy(() => import('./pages/PronunciationPage.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'; } 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); 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 [currentPage, setCurrentPage] = useState(savedStartPage);
const currentPageRef = useRef(currentPage);
currentPageRef.current = currentPage;
const [projectIndex, setProjectIndex] = useState([]); const [projectIndex, setProjectIndex] = useState([]);
const [saveStatus, setSaveStatus] = useState(''); const [saveStatus, setSaveStatus] = useState('');
const saveTimerRef = useRef(null); const saveTimerRef = useRef(null);
@@ -67,7 +77,7 @@ const App = () => {
if (scroller) scroller.scrollTop = 0; if (scroller) scroller.scrollTop = 0;
}, [currentPage]); }, [currentPage]);
// Keep the browser/OS chrome (status bar area, overscroll, scrollbars) in the chosen theme // 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({ const { authUser, setAuthUser, authServerDown, signIn, logout } = useAuth({
onSignIn: (user) => { onSignIn: (user) => {
setProjectIndex(switchStorageUser(user.id)); setProjectIndex(switchStorageUser(user.id));
@@ -100,6 +110,33 @@ const App = () => {
setCurrentPage, 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 // Startup: migrate old keys and load index
useEffect(() => { useEffect(() => {
migrateOldStorageKeys(); migrateOldStorageKeys();
@@ -109,10 +146,45 @@ const App = () => {
const allChunks = project ? project.chapters.flatMap((ch) => ch.chunks) : []; const allChunks = project ? project.chapters.flatMap((ch) => ch.chunks) : [];
// Autosave: 1s after the last edit, save locally, then sync to the server. // 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(() => { 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); if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
saveTimerRef.current = window.setTimeout(async () => { saveTimerRef.current = window.setTimeout(async () => {
savedSignatureRef.current = { id: project.id, signature };
// Stamp lastEdited once so localStorage and server receive the same value // Stamp lastEdited once so localStorage and server receive the same value
const toSave = { ...project, lastEdited: Date.now() }; const toSave = { ...project, lastEdited: Date.now() };
@@ -122,17 +194,34 @@ const App = () => {
setSaveStatus('Saved'); setSaveStatus('Saved');
window.setTimeout(() => setSaveStatus(''), 1400); window.setTimeout(() => setSaveStatus(''), 1400);
// 2. Sync to server with the same timestamped object // 2. Sync to server with the same timestamped object (not while a conflict is waiting on a choice)
setSyncStatus('syncing'); if (conflictRef.current?.id === toSave.id) return;
const result = await saveRemoteProject(toSave); await pushProject(toSave);
setSyncStatus(result.ok ? 'synced' : 'error');
// On success clear after 2.5s; on error keep visible until next successful sync
if (result.ok) window.setTimeout(() => setSyncStatus(''), 2500);
}, 1000); }, 1000);
return () => { return () => {
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current); 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) => { const updateProject = (updater) => {
setProject((current) => (current ? updater(current) : current)); setProject((current) => (current ? updater(current) : current));
@@ -230,7 +319,7 @@ const App = () => {
setCurrentPage('study'); setCurrentPage('study');
} catch (err) { } catch (err) {
console.error('studyReadingPlanDay error:', 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 { } finally {
setStudyBuildBusy(false); setStudyBuildBusy(false);
} }
@@ -266,12 +355,9 @@ const App = () => {
// Manual retry after a failed background sync (shown in the project header). // Manual retry after a failed background sync (shown in the project header).
const retrySync = async () => { const retrySync = async () => {
if (!project) return; if (!project) return;
setSyncStatus('syncing');
const toSave = { ...project, lastEdited: Date.now() }; const toSave = { ...project, lastEdited: Date.now() };
saveProjectToStorage(toSave); saveProjectToStorage(toSave);
const result = await saveRemoteProject(toSave); await pushProject(toSave);
setSyncStatus(result.ok ? 'synced' : 'error');
if (result.ok) window.setTimeout(() => setSyncStatus(''), 2500);
}; };
// Shared with every page via useApp(). Page-specific state lives in the // Shared with every page via useApp(). Page-specific state lives in the
@@ -291,6 +377,7 @@ const App = () => {
saveStatus, setSaveStatus, saveStatus, setSaveStatus,
syncStatus, syncStatus,
retrySync, retrySync,
syncConflict, keepMyVersion, useServerVersion,
// Home // Home
projectIndex, projectIndex,
...library, ...library,
@@ -303,6 +390,13 @@ const App = () => {
studyBuildBusy, studyBuildBusy,
openImportProject, openImportProject,
openNewProject, 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 (book player on Home + chapter player in the Reader)
...audio, ...audio,
// Reader // Reader
@@ -324,14 +418,26 @@ const App = () => {
<Suspense fallback={<PageLoading />}> <Suspense fallback={<PageLoading />}>
<AppRouter /> <AppRouter />
</Suspense> </Suspense>
{paletteOpen && <CommandPalette onClose={() => setPaletteOpen(false)} />}
{helpOpen && <ShortcutsHelp onClose={() => setHelpOpen(false)} />}
</AppContext.Provider> </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() { function PageLoading() {
const { readerTheme } = useApp();
return ( return (
<div className="flex min-h-dvh items-center justify-center bg-slate-900"> <div className="min-h-dvh bg-[var(--r-bg)]" style={themeVars(readerTheme)} role="status" aria-label="Loading">
<p className="text-sm text-slate-400">Loading…</p> <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> </div>
); );
} }
@@ -374,13 +480,7 @@ function AppRouter() {
); );
} }
if (authUser === undefined && !authServerDown) { if (authUser === undefined && !authServerDown) return <PageLoading />;
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 === null && !authServerDown) return <AuthPage />; if (authUser === null && !authServerDown) return <AuthPage />;
@@ -390,6 +490,7 @@ function AppRouter() {
if (currentPage === 'reader') return <ReaderPage />; if (currentPage === 'reader') return <ReaderPage />;
if (currentPage === 'import') return <ImportPage />; if (currentPage === 'import') return <ImportPage />;
if (currentPage === 'study') return <StudyPage />; if (currentPage === 'study') return <StudyPage />;
if (currentPage === 'outline') return <OutlinePage />;
if (currentPage === 'script') return <ScriptPage />; if (currentPage === 'script') return <ScriptPage />;
if (currentPage === 'export') return <ExportPage />; if (currentPage === 'export') return <ExportPage />;
if (currentPage === 'setup') return <SetupPage />; if (currentPage === 'setup') return <SetupPage />;
@@ -397,4 +498,11 @@ function AppRouter() {
return <HomePage />; return <HomePage />;
} }
// The toast provider sits outside so every page (and App itself) can show toasts.
const App = () => (
<ToastProvider>
<AppInner />
</ToastProvider>
);
export default App; export default App;
+12 -9
View File
@@ -588,14 +588,17 @@ describe('Home page', () => {
expect(JSON.parse(localStorage.getItem('bible-study-index'))[0].title).toBe('Titus Deep Dive'); expect(JSON.parse(localStorage.getItem('bible-study-index'))[0].title).toBe('Titus Deep Dive');
}); });
test('deleting a project removes it after confirmation', async () => { test('deleting a project removes it at once, and Undo brings it back', async () => {
seedProject(); seedProject();
window.confirm.mockReturnValue(true);
const user = userEvent.setup(); const user = userEvent.setup();
await renderApp(); await renderApp();
await user.click(screen.getByRole('button', { name: /^delete$/i })); await user.click(screen.getByRole('button', { name: /^delete$/i }));
await waitFor(() => expect(screen.queryByText('Titus Notes')).not.toBeInTheDocument()); await waitFor(() => expect(screen.queryByText('Titus Notes')).not.toBeInTheDocument());
expect(localStorage.getItem('bible-study-project-p1')).toBeNull(); expect(localStorage.getItem('bible-study-project-p1')).toBeNull();
expect(window.confirm).not.toHaveBeenCalled();
await user.click(await screen.findByRole('button', { name: /undo/i }));
expect(await screen.findByText('Titus Notes')).toBeInTheDocument();
expect(localStorage.getItem('bible-study-project-p1')).not.toBeNull();
}); });
test('sort choice is remembered after leaving Home', async () => { test('sort choice is remembered after leaving Home', async () => {
@@ -760,28 +763,28 @@ describe('Chunk builder', () => {
expect(await addSecondChapter(user)).toHaveAttribute('aria-current', 'true'); expect(await addSecondChapter(user)).toHaveAttribute('aria-current', 'true');
}); });
test('the Outline stage reopens the chapter of the chunk being studied', async () => { test('the Chunks stage reopens the chapter of the chunk being studied', async () => {
const user = await loadChapter(); const user = await loadChapter();
await addSecondChapter(user); await addSecondChapter(user);
addChunk('Paul, a servant', 'In hope of eternal'); // chunk in Titus 2 (now selected) addChunk('Paul, a servant', 'In hope of eternal'); // chunk in Titus 2 (now selected)
await user.click(screen.getByRole('button', { name: 'Titus 1' })); await user.click(screen.getByRole('button', { name: 'Titus 1' }));
await user.click(screen.getByRole('button', { name: /begin studying/i })); await user.click(screen.getByRole('button', { name: /begin studying/i }));
await findChunkCounter(1, 1); await findChunkCounter(1, 1);
await user.click(screen.getByRole('button', { name: /^outline$/i })); await user.click(screen.getByRole('button', { name: /^chunks$/i }));
expect(await screen.findByRole('button', { name: 'Titus 2' })).toHaveAttribute('aria-current', 'true'); expect(await screen.findByRole('button', { name: 'Titus 2' })).toHaveAttribute('aria-current', 'true');
}); });
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Stage bar (Outline / Study / Script / Export) // Stage bar (Chunks / Study / Outline / Script / Export)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe('Stage bar', () => { describe('Stage bar', () => {
test('later stages are disabled until a chunk exists', async () => { test('later stages are disabled until a chunk exists', async () => {
await loadChapter(); await loadChapter();
for (const name of [/^study$/i, /^script$/i, /^export$/i]) { for (const name of [/^study$/i, /^outline$/i, /^script$/i, /^export$/i]) {
expect(screen.getByRole('button', { name })).toBeDisabled(); expect(screen.getByRole('button', { name })).toBeDisabled();
} }
expect(screen.getByRole('button', { name: /^outline$/i })).not.toBeDisabled(); expect(screen.getByRole('button', { name: /^chunks$/i })).not.toBeDisabled();
addChunk('Paul, a servant', 'Paul, a servant'); addChunk('Paul, a servant', 'Paul, a servant');
await screen.findByText('1 created'); await screen.findByText('1 created');
expect(screen.getByRole('button', { name: /^study$/i })).not.toBeDisabled(); expect(screen.getByRole('button', { name: /^study$/i })).not.toBeDisabled();
@@ -1240,7 +1243,7 @@ describe('Back to the dashboard', () => {
await dashboard(); await dashboard();
}); });
test.each(['Outline', 'Study', 'Script', 'Export'])('the %s stage of a study', async (stage) => { test.each(['Chunks', 'Study', 'Outline', 'Script', 'Export'])('the %s stage of a study', async (stage) => {
const u = await loadChapter(); const u = await loadChapter();
addChunk('Paul, a servant', 'In hope of eternal'); addChunk('Paul, a servant', 'In hope of eternal');
await screen.findByText('1 created'); await screen.findByText('1 created');
@@ -1431,7 +1434,7 @@ describe('New pages start at the top', () => {
await screen.findByRole('heading', { name: /^reading plans$/i }); await screen.findByRole('heading', { name: /^reading plans$/i });
expect(scroller.scrollTop).toBe(0); expect(scroller.scrollTop).toBe(0);
scroller.scrollTop = 300; scroller.scrollTop = 300;
await user.click(screen.getByRole('button', { name: /back to home/i })); await user.click(screen.getByRole('button', { name: /back to home|bible study home/i }));
await screen.findByText('My Studies'); await screen.findByText('My Studies');
expect(scroller.scrollTop).toBe(0); expect(scroller.scrollTop).toBe(0);
}); });
+132
View File
@@ -0,0 +1,132 @@
// Press ⌘K (Ctrl+K) anywhere: type a passage ("john 3:16") to go straight to it, or a few letters to
// find a study, a note you wrote, a bookmark or highlight, a page, or to search the Bible's text.
import { useEffect, useMemo, useRef, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { themeVars } from './reader/readerThemes.js';
import { SearchIcon } from './reader/icons.jsx';
import { formatPassage, parsePassageRef } from '../utils/passageRef.js';
import { searchMarks, searchNotes, searchProjects } from '../utils/personalSearch.js';
import { loadProjectIndex } from '../utils/bibleUtils.jsx';
import { loadProjectById } from '../utils/projectStorage.js';
export default function CommandPalette({ onClose }) {
const {
readerTheme, authUser, authServerDown, projectIndex, readerBookmarks, readerTextHighlights, readerColorLabels,
jumpToReaderVerse, setCurrentPage, setProject, resumeProject, openNewProject, goStudies, openReadingPlans,
openBibleReader, requestReaderSearch, openBackup,
} = useApp();
const [query, setQuery] = useState('');
const [active, setActive] = useState(0);
const listRef = useRef(null);
const inputRef = useRef(null);
const opener = useRef(document.activeElement);
useEffect(() => {
inputRef.current?.focus();
const from = opener.current;
return () => { if (from instanceof HTMLElement && document.contains(from)) from.focus({ preventScroll: true }); };
}, []);
const items = useMemo(() => {
const out = [];
const q = query.trim();
const go = (fn) => () => { onClose(); fn(); };
// 1. A passage typed in
for (const r of parsePassageRef(q)) {
out.push({ group: 'Go to', label: formatPassage(r), hint: 'Open in the Bible', run: go(() => { jumpToReaderVerse(r.book, r.chapter, r.verse ?? 1); openBibleReader(); }) });
}
// 2. Pages and actions
const pages = [
['Studies', 'My Studies', goStudies], ['Read Bible', 'The Bible reader', openBibleReader], ['Reading Plans', 'Plans and today’s reading', openReadingPlans],
['New project', 'Start a Bible study', openNewProject], ['Settings', 'Account and preferences', () => setCurrentPage('settings')],
['Back up my data', 'Download everything as a file', openBackup],
];
const lower = q.toLowerCase();
for (const [label, hint, fn] of pages) {
if (!lower || label.toLowerCase().includes(lower)) out.push({ group: lower ? 'Pages' : 'Go to', label, hint, run: go(fn) });
}
if (q.length >= 2) {
// 3. Studies
for (const e of searchProjects(projectIndex, q)) {
out.push({ group: 'Studies', label: e.title, hint: e.chapterSummary, run: go(() => resumeProject(e.id)) });
}
// 4. Notes you wrote
const projects = loadProjectIndex().map((e) => loadProjectById(e.id)).filter(Boolean);
for (const h of searchNotes(projects, q)) {
out.push({
group: 'Your notes', label: `${h.ref} — ${h.projectTitle}`, hint: h.snippet,
run: go(() => {
const full = loadProjectById(h.projectId);
if (!full) return;
setProject({ ...full, selectedChunkId: h.chunkId });
setCurrentPage('study');
}),
});
}
// 5. Bookmarks and highlights
for (const m of searchMarks({ bookmarks: readerBookmarks, highlights: readerTextHighlights, labels: readerColorLabels ?? {} }, q)) {
out.push({
group: 'Bookmarks & highlights', label: `${m.bookName} ${m.chapter}:${m.verse}`, color: m.color,
hint: m.kind === 'highlight' ? `${m.label ? `${m.label}: ` : ''}${m.text ?? 'Highlight'}` : 'Bookmark',
run: go(() => { jumpToReaderVerse(m.book, m.chapter, m.verse); openBibleReader(); }),
});
}
// 6. The Bible's own text
out.push({ group: 'Search', label: `Search the Bible for “${q}”`, hint: 'Whole-Bible search in the reader', run: go(() => { requestReaderSearch(q); openBibleReader(); }) });
}
return out;
}, [query]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { setActive(0); }, [query]);
useEffect(() => { listRef.current?.querySelector('[aria-selected="true"]')?.scrollIntoView?.({ block: 'nearest' }); }, [active]);
const onKeyDown = (e) => {
if (e.key === 'ArrowDown') { e.preventDefault(); setActive((a) => Math.min(a + 1, items.length - 1)); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setActive((a) => Math.max(a - 1, 0)); }
else if (e.key === 'Enter') { e.preventDefault(); items[active]?.run(); }
else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onClose(); }
else if (e.key === 'Tab') { e.preventDefault(); } // the box and the list are one control
};
if (authUser === null && !authServerDown) return null; // the sign-in screen has nothing to search
let lastGroup = null;
return (
<div style={themeVars(readerTheme)} className="fixed inset-0 z-[90] flex items-start justify-center bg-black/40 px-3 pt-[12vh] font-sans" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div role="dialog" aria-modal="true" aria-label="Search and go to" className="w-full max-w-xl overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] text-[var(--r-text)] shadow-2xl">
<div className="flex items-center gap-2 border-b border-[var(--r-line)] px-4 py-3">
<span className="text-[var(--r-sub)]"><SearchIcon /></span>
<input
ref={inputRef} value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={onKeyDown}
role="combobox" aria-expanded="true" aria-controls="palette-list" aria-autocomplete="list"
aria-activedescendant={items[active] ? `palette-item-${active}` : undefined}
placeholder="Go to a passage, or search your studies and notes…"
className="min-w-0 flex-1 bg-transparent text-base outline-none placeholder:text-[var(--r-sub)]"
/>
<kbd className="hidden rounded border border-[var(--r-line)] px-1.5 py-0.5 text-[10px] text-[var(--r-sub)] sm:block">esc</kbd>
</div>
<ul id="palette-list" role="listbox" ref={listRef} className="max-h-[50vh] overflow-y-auto p-2">
{items.length === 0 && <li className="px-3 py-6 text-center text-sm text-[var(--r-sub)]">Nothing found. Try a book and chapter, like “romans 8”.</li>}
{items.map((item, i) => {
const header = item.group !== lastGroup ? item.group : null;
lastGroup = item.group;
return (
<li key={i} role="presentation">
{header && <p className="px-3 pb-1 pt-2 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">{header}</p>}
<div id={`palette-item-${i}`} role="option" aria-selected={i === active} onMouseMove={() => setActive(i)} onClick={item.run}
className={`flex cursor-pointer items-baseline gap-2 rounded-lg px-3 py-2 text-sm ${i === active ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : ''}`}>
{item.color && <span className="h-2.5 w-2.5 shrink-0 translate-y-px rounded-full border border-black/10" style={{ backgroundColor: item.color }} />}
<span className="shrink-0 font-medium">{item.label}</span>
{item.hint && <span className={`min-w-0 truncate text-xs ${i === active ? 'opacity-80' : 'text-[var(--r-sub)]'}`}>{item.hint}</span>}
</div>
</li>
);
})}
</ul>
<p className="border-t border-[var(--r-line)] px-4 py-2 text-[11px] text-[var(--r-sub)]">↑↓ to move · Enter to open · Esc to close</p>
</div>
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
// The "Save for offline" control: a button, or progress with a Stop button while a download runs.
import { canSaveOffline } from '../utils/offline.js';
export function DownloadProgress({ job, onCancel }) {
const pct = job.total ? Math.round((job.done / job.total) * 100) : 0;
return (
<div role="status" className="rounded-xl border border-[var(--r-line)] px-3 py-2 text-xs">
<div className="mb-1.5 flex items-center justify-between gap-2">
<span>Saving {job.label}… {job.done} of {job.total}</span>
<button type="button" onClick={onCancel} className="text-[var(--r-accent)] hover:underline">Stop</button>
</div>
<div className="h-1 overflow-hidden rounded-full bg-[var(--r-line)]"><div className="h-full bg-[var(--r-accent)] transition-[width]" style={{ width: `${pct}%` }} /></div>
</div>
);
}
// One book of a translation.
export function SaveBookOffline({ offline, translation, book, name }) {
if (!canSaveOffline(translation)) {
return <p className="mt-4 text-[11px] text-[var(--r-sub)]">Saving for offline works with the BSB and NET. The {translation}’s licence doesn’t allow it.</p>;
}
if (offline.job) return <div className="mt-4"><DownloadProgress job={offline.job} onCancel={offline.cancel} /></div>;
const saved = offline.isSaved(translation, book);
return (
<div className="mt-4 flex items-center justify-between gap-3 rounded-xl border border-[var(--r-line)] px-3 py-2 text-sm">
<span className={saved ? '' : 'text-[var(--r-sub)]'}>{saved ? `✓ ${name} (${translation}) is saved for offline` : `Read ${name} without a connection`}</span>
{!saved && (
<button type="button" onClick={() => offline.saveBook(translation, book, name)} className="shrink-0 rounded-full border border-[var(--r-line)] px-3 py-1 text-xs font-medium hover:bg-black/5">Save for offline</button>
)}
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
// The "?" cheat sheet: every keyboard shortcut for the page you are on, plus the ones that work everywhere.
import { useEffect, useRef } from 'react';
import { useApp } from '../context/AppContext.js';
import { themeVars } from './reader/readerThemes.js';
const GLOBAL = [
['⌘K / Ctrl K', 'Search and go to a passage'],
['?', 'Show this list'],
['Esc', 'Close whatever is open'],
];
const BY_PAGE = {
reader: [
['← →', 'Previous / next chapter'],
['/', 'Search this chapter or the whole Bible'],
['G', 'Pick a book and chapter'],
['B', 'Bookmark the selected verse'],
[',', 'Reading settings'],
['F', 'Hide or show the controls'],
],
study: [
['← →', 'Previous / next chunk'],
],
};
export default function ShortcutsHelp({ onClose }) {
const { readerTheme, currentPage } = useApp();
const closeRef = useRef(null);
useEffect(() => {
const from = document.activeElement;
closeRef.current?.focus();
const onKey = (e) => { if (e.key === 'Escape' || e.key === '?') { e.preventDefault(); e.stopPropagation(); onClose(); } };
document.addEventListener('keydown', onKey, true);
return () => { document.removeEventListener('keydown', onKey, true); if (from instanceof HTMLElement && document.contains(from)) from.focus({ preventScroll: true }); };
}, [onClose]);
const page = BY_PAGE[currentPage] ?? [];
const section = (title, rows) => (
<div className="mb-4 last:mb-0">
<h3 className="mb-1.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">{title}</h3>
<dl className="space-y-1.5">
{rows.map(([keys, what]) => (
<div key={keys} className="flex items-center justify-between gap-4 text-sm">
<dt><kbd className="rounded-md border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-0.5 font-mono text-xs">{keys}</kbd></dt>
<dd className="text-right text-[var(--r-sub)]">{what}</dd>
</div>
))}
</dl>
</div>
);
return (
<div style={themeVars(readerTheme)} className="fixed inset-0 z-[90] flex items-center justify-center bg-black/40 px-3 font-sans" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div role="dialog" aria-modal="true" aria-label="Keyboard shortcuts" className="max-h-[80vh] w-full max-w-sm overflow-y-auto rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5 text-[var(--r-text)] shadow-2xl">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-base font-medium">Keyboard shortcuts</h2>
<button ref={closeRef} type="button" onClick={onClose} className="rounded-lg px-2 py-1 text-sm text-[var(--r-sub)] hover:bg-black/5">Close</button>
</div>
{page.length > 0 && section(currentPage === 'reader' ? 'In the Bible' : 'In this study', page)}
{section('Everywhere', GLOBAL)}
</div>
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
// App-wide toasts: short messages at the bottom of the screen, optionally with one
// action button (usually "Undo"). Used instead of alert()/confirm() so a slip is
// reversible and nothing blocks the page.
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { themeVars } from './reader/readerThemes.js';
const ToastContext = createContext(null);
// toast(message, { action: { label, onClick }, ms, tone: 'info' | 'error' }) -> id
// Outside a provider (a page rendered on its own in a test) it quietly does nothing.
const NOOP = { toast: () => 0, dismiss: () => {}, setTheme: () => {} };
export const useToast = () => useContext(ToastContext) ?? NOOP;
let nextId = 1;
export function ToastProvider({ children }) {
const [toasts, setToasts] = useState([]);
const [theme, setTheme] = useState('paper'); // set by the app so toasts match the reading theme
const timers = useRef(new Map());
const dismiss = useCallback((id) => {
window.clearTimeout(timers.current.get(id));
timers.current.delete(id);
setToasts((list) => list.filter((t) => t.id !== id));
}, []);
const toast = useCallback((message, { action, ms, tone = 'info' } = {}) => {
const id = nextId++;
// Messages with an action stay long enough to read and react to.
const duration = ms ?? (action ? 8000 : tone === 'error' ? 6000 : 3500);
setToasts((list) => [...list.slice(-2), { id, message, action, tone }]);
timers.current.set(id, window.setTimeout(() => dismiss(id), duration));
return id;
}, [dismiss]);
useEffect(() => {
const live = timers.current;
return () => live.forEach((t) => window.clearTimeout(t));
}, []);
const value = useMemo(() => ({ toast, dismiss, setTheme }), [toast, dismiss]);
return (
<ToastContext.Provider value={value}>
{children}
<div style={themeVars(theme)} className="pointer-events-none fixed inset-x-0 bottom-0 z-[100] flex flex-col items-center gap-2 px-4 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] font-sans">
{toasts.map((t) => (
<div key={t.id} role={t.tone === 'error' ? 'alert' : 'status'}
className={`pointer-events-auto flex max-w-md items-center gap-3 rounded-full px-4 py-2.5 text-sm shadow-lg ${t.tone === 'error' ? 'bg-[var(--r-danger)] text-[var(--r-bg)]' : 'bg-[var(--r-text)] text-[var(--r-bg)]'}`}>
<span>{t.message}</span>
{t.action && (
<button type="button" onClick={() => { t.action.onClick(); dismiss(t.id); }}
className="shrink-0 rounded-full px-2 py-0.5 text-sm font-semibold underline underline-offset-2 hover:opacity-80">
{t.action.label}
</button>
)}
</div>
))}
</div>
</ToastContext.Provider>
);
}
+26 -4
View File
@@ -5,7 +5,10 @@ import { useRef, useState } from 'react';
import Sheet from '../reader/Sheet.jsx'; import Sheet from '../reader/Sheet.jsx';
import TranslationSelect from './TranslationSelect.jsx'; import TranslationSelect from './TranslationSelect.jsx';
import { resizeImageToDataUrl } from './CustomPlanBuilder.jsx'; import { resizeImageToDataUrl } from './CustomPlanBuilder.jsx';
import { formatDayReadings, getPlanSchedule, daysBehindPace } from '../../utils/readingPlans.js'; import { formatDayReadings, getPlanSchedule, daysBehindPace, nextUnreadDayIndex } from '../../utils/readingPlans.js';
import { useOfflineSave } from '../../hooks/useOfflineSave.js';
import { DownloadProgress } from '../OfflineSave.jsx';
import { canSaveOffline } from '../../utils/offline.js';
const btn = 'rounded-xl border border-[var(--r-line)] px-3 py-2 text-sm hover:bg-black/5'; const btn = 'rounded-xl border border-[var(--r-line)] px-3 py-2 text-sm hover:bg-black/5';
const primary = 'w-full rounded-xl bg-[var(--r-accent)] py-2.5 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50'; const primary = 'w-full rounded-xl bg-[var(--r-accent)] py-2.5 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50';
@@ -14,6 +17,13 @@ function ActiveBody({ plan, progress, onClose, actions }) {
const schedule = getPlanSchedule(plan.id); const schedule = getPlanSchedule(plan.id);
const behind = daysBehindPace(plan, progress); const behind = daysBehindPace(plan, progress);
const pct = plan.days > 0 ? Math.round((progress.completedDays.length / plan.days) * 100) : 0; const pct = plan.days > 0 ? Math.round((progress.completedDays.length / plan.days) * 100) : 0;
const offline = useOfflineSave();
const translation = progress.translation ?? actions.defaultTranslation;
// The next week of readings, so a trip or a bad connection doesn't interrupt the plan
const upcoming = (() => {
const from = nextUnreadDayIndex(plan, progress.completedDays);
return from == null ? [] : schedule.slice(from, from + 7).flat();
})();
return ( return (
<> <>
<div className="mb-3 flex items-center justify-between text-sm"> <div className="mb-3 flex items-center justify-between text-sm">
@@ -42,9 +52,21 @@ function ActiveBody({ plan, progress, onClose, actions }) {
); );
})} })}
</div> </div>
{upcoming.length > 0 && canSaveOffline(translation) && (
<div className="mt-3">
{offline.job
? <DownloadProgress job={offline.job} onCancel={offline.cancel} />
: (
<button type="button" onClick={() => offline.saveRefs('the next 7 days', translation, upcoming)}
className="w-full rounded-xl border border-[var(--r-line)] px-3 py-2 text-xs text-[var(--r-sub)] hover:bg-black/5">
Save the next 7 days for offline ({translation})
</button>
)}
</div>
)}
<div className="mt-4 flex items-center justify-between text-xs"> <div className="mt-4 flex items-center justify-between text-xs">
<button type="button" onClick={() => { if (window.confirm('Restart this plan from day 1?')) { actions.restart(progress.id); onClose(); } }} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">Restart</button> <button type="button" onClick={() => { actions.restart(progress.id); onClose(); }} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">Restart</button>
<button type="button" onClick={() => { if (window.confirm('Remove this plan?')) { actions.remove(progress.id); onClose(); } }} className="text-[var(--r-danger,#b42318)] hover:underline">Remove</button> <button type="button" onClick={() => { actions.remove(progress.id); onClose(); }} className="text-[var(--r-danger,#b42318)] hover:underline">Remove</button>
</div> </div>
</> </>
); );
@@ -92,7 +114,7 @@ function CatalogBody({ plan, active, completedCount, onClose, actions }) {
<button type="button" onClick={() => { onClose(); actions.edit(plan.id); }} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">Edit plan</button> <button type="button" onClick={() => { onClose(); actions.edit(plan.id); }} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">Edit plan</button>
<button type="button" onClick={() => fileRef.current?.click()} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">{plan.image ? 'Change cover' : 'Add cover photo'}</button> <button type="button" onClick={() => fileRef.current?.click()} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">{plan.image ? 'Change cover' : 'Add cover photo'}</button>
{plan.image && <button type="button" onClick={() => actions.updateCover(plan.id, null)} className="text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]">Remove cover</button>} {plan.image && <button type="button" onClick={() => actions.updateCover(plan.id, null)} className="text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]">Remove cover</button>}
<button type="button" onClick={() => { if (window.confirm('Delete this plan? Its progress history will be removed too.')) { actions.delete(plan.id); onClose(); } }} className="ml-auto text-[var(--r-danger,#b42318)] hover:underline">Delete</button> <button type="button" onClick={() => { actions.delete(plan.id); onClose(); }} className="ml-auto text-[var(--r-danger,#b42318)] hover:underline">Delete</button>
<input ref={fileRef} type="file" accept="image/*" onChange={pickImage} className="hidden" /> <input ref={fileRef} type="file" accept="image/*" onChange={pickImage} className="hidden" />
{imageError && <p className="w-full text-[var(--r-danger,#b42318)]">{imageError}</p>} {imageError && <p className="w-full text-[var(--r-danger,#b42318)]">{imageError}</p>}
</div> </div>
-20
View File
@@ -1,20 +0,0 @@
import Sheet from './Sheet.jsx';
import { CloseIcon } from './icons.jsx';
export default function BookmarksSheet({ open, onClose, entries, onJump, onRemove }) {
return (
<Sheet open={open} onClose={onClose} title={`Bookmarks${entries.length ? ` (${entries.length})` : ''}`}>
{entries.length === 0 ? (
<p className="text-sm text-[var(--r-sub)]">No bookmarks yet. Tap a verse, then the bookmark icon, to save it here.</p>
) : entries.map((e) => (
<div key={e.key} className="flex items-center gap-2 rounded-lg px-1 py-1.5 hover:bg-black/5">
<span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: e.color }} />
<button type="button" onClick={() => onJump(e.bookAbbrev, e.chapter, e.verse)} className="flex-1 truncate text-left text-sm">
{e.bookName} {e.chapter}:{e.verse}
</button>
<button type="button" onClick={() => onRemove(e.key)} aria-label="Remove bookmark" className="rounded p-1 text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]"><CloseIcon /></button>
</div>
))}
</Sheet>
);
}
+94
View File
@@ -0,0 +1,94 @@
// Everything you have marked in the Bible: highlights (grouped and filtered by colour, each colour
// named by you — "Promises", "Commands") and bookmarks. Opens from the bookmark button in the top bar.
import { useState } from 'react';
import Sheet from './Sheet.jsx';
import { CloseIcon } from './icons.jsx';
import { COLOR_NAMES, COLOR_SUGGESTIONS, HIGHLIGHT_COLORS, colorTitle } from '../../utils/highlightColors.js';
const tabBtn = (active) => `rounded-full border px-3 py-1 text-xs font-medium ${active ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`;
export default function MarksSheet({
open, onClose, bookmarks, highlights, colorLabels, onSetLabel, onJump, onRemoveBookmark, onRemoveHighlight,
}) {
const [tab, setTab] = useState('highlights');
const [filter, setFilter] = useState(null); // a highlight colour, or null for all
const [editing, setEditing] = useState(false);
const counts = Object.fromEntries(HIGHLIGHT_COLORS.map((c) => [c, highlights.filter((h) => h.color === c).length]));
const shown = filter ? highlights.filter((h) => h.color === filter) : highlights;
// Colours in use that aren't one of the picker's (older data) still get listed
const total = highlights.length + bookmarks.length;
return (
<Sheet open={open} onClose={onClose} label="Bookmarks and highlights" title={`Bookmarks & highlights${total ? ` (${total})` : ''}`}>
<div role="tablist" aria-label="What to show" className="mb-3 flex gap-1.5">
<button type="button" role="tab" aria-selected={tab === 'highlights'} onClick={() => setTab('highlights')} className={tabBtn(tab === 'highlights')}>Highlights ({highlights.length})</button>
<button type="button" role="tab" aria-selected={tab === 'bookmarks'} onClick={() => setTab('bookmarks')} className={tabBtn(tab === 'bookmarks')}>Bookmarks ({bookmarks.length})</button>
</div>
{tab === 'highlights' && (
<>
{/* Colour key: tap a colour to show only that one; "Name colours" lets you say what each means */}
<div className="mb-3 flex flex-wrap items-center gap-1.5" role="group" aria-label="Filter by colour">
<button type="button" onClick={() => setFilter(null)} aria-pressed={filter === null} className={tabBtn(filter === null)}>All</button>
{HIGHLIGHT_COLORS.map((c) => (
<button key={c} type="button" onClick={() => setFilter(filter === c ? null : c)} aria-pressed={filter === c}
title={colorTitle(c, colorLabels)}
className={`flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs ${filter === c ? 'border-[var(--r-text)]' : 'border-[var(--r-line)] hover:bg-black/5'}`}>
<span className="h-3 w-3 rounded-full border border-black/10" style={{ backgroundColor: c }} />
<span>{colorLabels[c] || COLOR_NAMES[c]}</span>
<span className="text-[var(--r-sub)]">{counts[c]}</span>
</button>
))}
<button type="button" onClick={() => setEditing((e) => !e)} className="ml-auto text-xs text-[var(--r-accent)] hover:underline">{editing ? 'Done' : 'Name colours'}</button>
</div>
{editing && (
<div className="mb-3 grid gap-2 rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-3 sm:grid-cols-2">
{HIGHLIGHT_COLORS.map((c) => (
<label key={c} className="flex items-center gap-2 text-xs text-[var(--r-sub)]">
<span className="h-4 w-4 shrink-0 rounded-full border border-black/10" style={{ backgroundColor: c }} />
<span className="sr-only">{COLOR_NAMES[c]} means</span>
<input value={colorLabels[c] ?? ''} maxLength={30} onChange={(e) => onSetLabel(c, e.target.value)} placeholder={COLOR_SUGGESTIONS[c]}
className="min-w-0 flex-1 rounded-lg border border-[var(--r-line)] bg-transparent px-2 py-1 text-sm text-[var(--r-text)] outline-none focus:border-[var(--r-accent)]" />
</label>
))}
</div>
)}
{highlights.length === 0 ? (
<p className="text-sm text-[var(--r-sub)]">No highlights yet. Select some words in a verse and pick a colour to save them here.</p>
) : shown.length === 0 ? (
<p className="text-sm text-[var(--r-sub)]">Nothing highlighted in that colour yet.</p>
) : shown.map((h) => (
<div key={h.id} className="flex items-start gap-2 rounded-lg px-1 py-1.5 hover:bg-black/5">
<span className="mt-1 h-3 w-3 shrink-0 rounded-full border border-black/10" style={{ backgroundColor: h.color }} title={colorTitle(h.color, colorLabels)} />
<button type="button" onClick={() => onJump(h.book, h.chapter, h.verse)} className="min-w-0 flex-1 text-left text-sm">
<span className="font-medium">{h.bookName} {h.chapter}:{h.verse}</span>
{colorLabels[h.color] && <span className="ml-1.5 text-[11px] text-[var(--r-sub)]">{colorLabels[h.color]}</span>}
{h.text && <span className="mt-0.5 block font-serif text-[var(--r-sub)]">“{h.text}”</span>}
</button>
<button type="button" onClick={() => onRemoveHighlight(h)} aria-label={`Remove highlight on ${h.bookName} ${h.chapter}:${h.verse}`}
className="rounded p-1 text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]"><CloseIcon /></button>
</div>
))}
</>
)}
{tab === 'bookmarks' && (
bookmarks.length === 0 ? (
<p className="text-sm text-[var(--r-sub)]">No bookmarks yet. Tap a verse, then the bookmark icon, to save it here.</p>
) : bookmarks.map((e) => (
<div key={e.key} className="flex items-center gap-2 rounded-lg px-1 py-1.5 hover:bg-black/5">
<span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: e.color }} />
<button type="button" onClick={() => onJump(e.bookAbbrev, e.chapter, e.verse)} className="flex-1 truncate text-left text-sm">
{e.bookName} {e.chapter}:{e.verse}
</button>
<button type="button" onClick={() => onRemoveBookmark(e)} aria-label={`Remove bookmark on ${e.bookName} ${e.chapter}:${e.verse}`}
className="rounded p-1 text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]"><CloseIcon /></button>
</div>
))
)}
</Sheet>
);
}
+7 -3
View File
@@ -2,12 +2,13 @@
import { useState } from 'react'; import { useState } from 'react';
import Sheet from './Sheet.jsx'; import Sheet from './Sheet.jsx';
import { bookOptions, CHAPTER_COUNTS } from '../../utils/bibleUtils.jsx'; import { bookOptions, CHAPTER_COUNTS } from '../../utils/bibleUtils.jsx';
import { READER_TRANSLATIONS } from '../../hooks/useBibleReader.js'; import { READER_TRANSLATIONS } from '../../utils/bibleText.js';
import { SaveBookOffline } from '../OfflineSave.jsx';
const NT_START = bookOptions.findIndex((b) => b.abbrev === 'MAT'); const NT_START = bookOptions.findIndex((b) => b.abbrev === 'MAT');
const chip = 'rounded-lg border px-2 py-2 text-center text-sm transition'; const chip = 'rounded-lg border px-2 py-2 text-center text-sm transition';
export default function NavigatorSheet({ open, onClose, bookAbbrev, chapter, translation, onTranslation, translationNote, onGo }) { export default function NavigatorSheet({ open, onClose, bookAbbrev, chapter, translation, onTranslation, translationNote, onGo, offline }) {
const [pickedBook, setPickedBook] = useState(null); // book abbrev whose chapters are showing const [pickedBook, setPickedBook] = useState(null); // book abbrev whose chapters are showing
const close = () => { setPickedBook(null); onClose(); }; const close = () => { setPickedBook(null); onClose(); };
const book = pickedBook ? bookOptions.find((b) => b.abbrev === pickedBook) : null; const book = pickedBook ? bookOptions.find((b) => b.abbrev === pickedBook) : null;
@@ -20,7 +21,7 @@ export default function NavigatorSheet({ open, onClose, bookAbbrev, chapter, tra
); );
return ( return (
<Sheet open={open} onClose={close} title={book ? ( <Sheet open={open} onClose={close} label="Go to" title={book ? (
<button type="button" onClick={() => setPickedBook(null)} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">‹ All books · <span className="text-[var(--r-text)]">{book.name}</span></button> <button type="button" onClick={() => setPickedBook(null)} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">‹ All books · <span className="text-[var(--r-text)]">{book.name}</span></button>
) : 'Go to'}> ) : 'Go to'}>
{!book && ( {!book && (
@@ -41,6 +42,7 @@ export default function NavigatorSheet({ open, onClose, bookAbbrev, chapter, tra
</> </>
)} )}
{book && ( {book && (
<>
<div className="grid grid-cols-6 gap-1.5 sm:grid-cols-8"> <div className="grid grid-cols-6 gap-1.5 sm:grid-cols-8">
{Array.from({ length: CHAPTER_COUNTS[book.abbrev] ?? 1 }, (_, i) => i + 1).map((n) => ( {Array.from({ length: CHAPTER_COUNTS[book.abbrev] ?? 1 }, (_, i) => i + 1).map((n) => (
<button key={n} type="button" onClick={() => { onGo(book.abbrev, n); close(); }} <button key={n} type="button" onClick={() => { onGo(book.abbrev, n); close(); }}
@@ -49,6 +51,8 @@ export default function NavigatorSheet({ open, onClose, bookAbbrev, chapter, tra
</button> </button>
))} ))}
</div> </div>
{offline && <SaveBookOffline offline={offline} translation={translation} book={book.abbrev} name={book.name} />}
</>
)} )}
</Sheet> </Sheet>
); );
+82 -10
View File
@@ -2,8 +2,18 @@
// serif face. Tap a verse to open the verse sheet; select text to highlight. // serif face. Tap a verse to open the verse sheet; select text to highlight.
import { formatCrossRef } from '../../utils/wordStudy.js'; import { formatCrossRef } from '../../utils/wordStudy.js';
// How each typeface choice is set. "Easy to read" is a sans face with extra letter and word spacing.
const FONT_STYLES = {
serif: { fontFamily: undefined },
sans: { fontFamily: "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif" },
easy: { fontFamily: "'Atkinson Hyperlegible', Verdana, 'Trebuchet MS', ui-sans-serif, sans-serif", letterSpacing: '0.02em', wordSpacing: '0.12em' },
};
const LINE_HEIGHTS = { snug: 1.6, normal: 1.85, airy: 2.15 };
// `parallel` ({ translation, name, notice, verses, loading, error }) shows a second Bible beside the first.
export default function ReaderText({ export default function ReaderText({
title, translation, notice, verses, loading, error, layout, fontSize, wide, title, translation, translationId, notice, verses, loading, error, layout, fontSize, wide,
font = 'serif', spacing = 'normal', showVerseNumbers = true, parallel = null,
bookmarks, bookKey, chapter, selectedVerse, onSelectVerse, renderVerseText, searchLower, bookmarks, bookKey, chapter, selectedVerse, onSelectVerse, renderVerseText, searchLower,
crossRefs, showCrossRefs, onSelectionEnd, footer, crossRefs, showCrossRefs, onSelectionEnd, footer,
}) { }) {
@@ -20,7 +30,12 @@ export default function ReaderText({
id: `reader-verse-${v.number}`, id: `reader-verse-${v.number}`,
'data-verse-num': v.number, 'data-verse-num': v.number,
onClick: () => handleTap(v.number), onClick: () => handleTap(v.number),
className: 'cursor-pointer rounded-sm transition-colors', // Verses can be reached and chosen from the keyboard too
role: 'button',
tabIndex: 0,
'aria-pressed': selected,
onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleTap(v.number); } },
className: 'cursor-pointer rounded-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[var(--r-accent)]',
style: selected style: selected
? { backgroundColor: 'var(--r-hl)' } ? { backgroundColor: 'var(--r-hl)' }
: bm ? { backgroundColor: `${bm}66`, boxShadow: `0 0 0 2px ${bm}66` } : undefined, : bm ? { backgroundColor: `${bm}66`, boxShadow: `0 0 0 2px ${bm}66` } : undefined,
@@ -35,25 +50,71 @@ export default function ReaderText({
</span> </span>
); );
const num = (n) => <sup className="mr-1 select-none font-sans text-[0.6em] text-[var(--r-sub)]">{n}</sup>; const num = (n) => (showVerseNumbers ? <sup className="mr-1 select-none font-sans text-[0.6em] text-[var(--r-sub)]">{n}</sup> : null);
// Both Bibles by verse number, so a verse one translation leaves out still lines up with the other.
const parallelRows = parallel && !parallel.error ? (() => {
const second = new Map(parallel.verses.map((v) => [v.number, v]));
const first = new Map(verses.map((v) => [v.number, v]));
return [...new Set([...first.keys(), ...second.keys()])].sort((a, b) => a - b)
.map((n) => ({ number: n, first: first.get(n), second: second.get(n) }));
})() : null;
const sideBySide = !!parallel;
const bodyStyle = {
...FONT_STYLES[font],
lineHeight: LINE_HEIGHTS[spacing] ?? LINE_HEIGHTS.normal,
};
// The page grows with the screen: type is 18px on a phone, ~20px on an iPad and ~24px on a big
// monitor (times your text-size choice), and the column is measured in the type's own width (em), so
// the line length stays comfortable (~70 characters) instead of becoming a narrow strip on a wide
// screen. Two columns / side-by-side take more room.
const pageStyle = {
fontSize: `calc(clamp(1.125rem, 0.9rem + 0.7vw, 1.55rem) * ${fontSize})`,
maxWidth: wide || sideBySide ? 'min(100%, 72em)' : 'min(100%, 40em)',
};
return ( return (
<article <article
className={`mx-auto px-5 pb-32 pt-6 text-[var(--r-text)] ${wide ? 'max-w-5xl' : 'max-w-2xl'}`} className="mx-auto px-5 pb-32 pt-6 text-[var(--r-text)] md:px-8"
style={pageStyle}
onPointerUp={onSelectionEnd} onPointerUp={onSelectionEnd}
> >
<header className="mb-5 text-center"> <header className="mb-5 text-center">
<h1 className="font-serif text-3xl font-normal tracking-tight">{title}</h1> <h1 className="font-serif text-3xl font-normal tracking-tight">{title}</h1>
<p className="mt-1 font-sans text-xs text-[var(--r-sub)]">{translation}</p> <p className="mt-1 font-sans text-xs text-[var(--r-sub)]">{translation}{parallel ? ` · beside ${parallel.name}` : ''}</p>
</header> </header>
{loading && <p className="text-center font-sans text-sm text-[var(--r-sub)]">Loading…</p>} {loading && <ChapterSkeleton />}
{error && <p className="text-center font-sans text-sm text-[var(--r-danger,#b42318)]">{error}</p>} {error && <p role="alert" className="text-center font-sans text-sm text-[var(--r-danger,#b42318)]">{error}</p>}
{!loading && !error && parallel?.error && (
<p role="alert" className="mb-4 text-center font-sans text-sm text-[var(--r-danger,#b42318)]">Couldn’t load {parallel.name}: {parallel.error}</p>
)}
{!loading && !error && ( {!loading && !error && sideBySide && !parallel.error && (
<div style={bodyStyle} className="font-serif">
<div className="mb-3 hidden grid-cols-2 gap-x-10 border-b border-[var(--r-line)] pb-1 font-sans text-[11px] uppercase tracking-wide text-[var(--r-sub)] md:grid">
<span>{translationId}</span><span>{parallel.translation}</span>
</div>
{parallelRows.map((row) => (
<div key={row.number} className="mb-4 grid gap-x-10 gap-y-1 md:grid-cols-2">
<p>
{row.first
? <span {...verseProps(row.first)}>{num(row.first.number)}{renderVerseText(row.first.number, row.first.text, searchLower)}{chips(row.first)}</span>
: <span className="text-[var(--r-sub)]">{num(row.number)}—</span>}
</p>
<p className="border-l-2 border-[var(--r-line)] pl-3 text-[0.95em] text-[var(--r-sub)] md:border-l-0 md:pl-0 md:text-[var(--r-text)]">
<span className="mr-1.5 font-sans text-[10px] uppercase tracking-wide md:hidden">{parallel.translation}</span>
{row.second ? <>{num(row.second.number)}{row.second.text}</> : parallel.loading ? <span className="font-sans text-xs" aria-label="Loading">…</span> : '—'}
</p>
</div>
))}
</div>
)}
{!loading && !error && !sideBySide && (
<div <div
className={`font-serif ${wide ? 'md:columns-2 md:gap-x-12' : ''}`} className={`font-serif ${wide ? 'md:columns-2 md:gap-x-12' : ''}`}
style={{ fontSize: `${1.125 * fontSize}rem`, lineHeight: 1.85 }} style={bodyStyle}
> >
{layout === 'flow' ? ( {layout === 'flow' ? (
<p className="text-pretty"> <p className="text-pretty">
@@ -73,9 +134,20 @@ export default function ReaderText({
</div> </div>
)} )}
{!loading && !error && footer} {!loading && !error && <div style={{ fontSize: '1rem' }}>{footer}</div>}
{notice && !loading && <p className="mt-10 border-t border-[var(--r-line)] pt-3 font-sans text-[11px] leading-snug text-[var(--r-sub)]">{notice}</p>} {notice && !loading && <p className="mt-10 border-t border-[var(--r-line)] pt-3 font-sans text-[11px] leading-snug text-[var(--r-sub)]">{notice}</p>}
{parallel?.notice && !loading && !parallel.loading && <p className="mt-2 font-sans text-[11px] leading-snug text-[var(--r-sub)]">{parallel.notice}</p>}
</article> </article>
); );
} }
// Grey bars the shape of a page, so a slow chapter load doesn't just show the word "Loading".
function ChapterSkeleton() {
return (
<div role="status" aria-label="Loading chapter" className="animate-pulse motion-reduce:animate-none space-y-3 pt-2">
{[100, 96, 92, 98, 84, 100, 90, 70].map((w, i) => <div key={i} className="h-4 rounded bg-[var(--r-line)]" style={{ width: `${w}%` }} />)}
<span className="sr-only">Loading…</span>
</div>
);
}
+41 -11
View File
@@ -1,42 +1,72 @@
// "Aa" sheet: text size, theme, and how verses are laid out. // "Aa" sheet: text size, typeface, spacing, theme, how verses are laid out, and a second Bible
// to read beside the first.
import Sheet from './Sheet.jsx'; import Sheet from './Sheet.jsx';
import { READER_THEMES } from './readerThemes.js'; import { READER_THEMES } from './readerThemes.js';
import { READER_TRANSLATIONS } from '../../utils/bibleText.js';
const SIZES = [['S', 0.875], ['M', 1], ['L', 1.125], ['XL', 1.25], ['XXL', 1.5]]; const SIZES = [['S', 0.875], ['M', 1], ['L', 1.125], ['XL', 1.25], ['XXL', 1.5]];
function Segmented({ options, value, onChange }) { function Segmented({ options, value, onChange, label }) {
return ( return (
<div className="flex overflow-hidden rounded-xl border border-[var(--r-line)]"> <div role="group" aria-label={label} className="flex overflow-hidden rounded-xl border border-[var(--r-line)]">
{options.map(([id, label]) => ( {options.map(([id, text]) => (
<button key={id} type="button" onClick={() => onChange(id)} <button key={String(id)} type="button" onClick={() => onChange(id)} aria-pressed={value === id}
className={`flex-1 px-3 py-2 text-sm ${value === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}> className={`flex-1 px-3 py-2 text-sm ${value === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>
{label} {text}
</button> </button>
))} ))}
</div> </div>
); );
} }
export default function SettingsSheet({ open, onClose, fontSize, onFontSize, theme, onTheme, layout, onLayout, wide, onWide }) { export default function SettingsSheet({
open, onClose, fontSize, onFontSize, theme, onTheme, layout, onLayout, wide, onWide,
font, onFont, spacing, onSpacing, verseNumbers, onVerseNumbers,
translation, parallel, onParallel,
}) {
const row = 'mb-4'; const row = 'mb-4';
const label = 'mb-1.5 block text-xs text-[var(--r-sub)]'; const label = 'mb-1.5 block text-xs text-[var(--r-sub)]';
return ( return (
<Sheet open={open} onClose={onClose} title="Reading settings"> <Sheet open={open} onClose={onClose} title="Reading settings">
<div className={row}> <div className={row}>
<span className={label}>Text size</span> <span className={label}>Text size</span>
<Segmented options={SIZES.map(([l, s]) => [s, l])} value={fontSize} onChange={onFontSize} /> <Segmented label="Text size" options={SIZES.map(([l, s]) => [s, l])} value={fontSize} onChange={onFontSize} />
</div>
<div className={row}>
<span className={label}>Typeface</span>
<Segmented label="Typeface" options={[['serif', 'Serif'], ['sans', 'Sans'], ['easy', 'Easy to read']]} value={font} onChange={onFont} />
</div>
<div className={row}>
<span className={label}>Line spacing</span>
<Segmented label="Line spacing" options={[['snug', 'Snug'], ['normal', 'Normal'], ['airy', 'Airy']]} value={spacing} onChange={onSpacing} />
</div> </div>
<div className={row}> <div className={row}>
<span className={label}>Theme</span> <span className={label}>Theme</span>
<Segmented options={Object.entries(READER_THEMES).map(([id, t]) => [id, t.label])} value={theme} onChange={onTheme} /> <Segmented label="Theme" options={Object.entries(READER_THEMES).map(([id, t]) => [id, t.label])} value={theme} onChange={onTheme} />
</div> </div>
<div className={row}> <div className={row}>
<span className={label}>Verses</span> <span className={label}>Verses</span>
<Segmented options={[['flow', 'Flowing'], ['verses', 'One per line']]} value={layout} onChange={onLayout} /> <Segmented label="Verse layout" options={[['flow', 'Flowing'], ['verses', 'One per line']]} value={layout} onChange={onLayout} />
</div>
<div className={row}>
<span className={label}>Verse numbers</span>
<Segmented label="Verse numbers" options={[[true, 'Show'], [false, 'Hide']]} value={verseNumbers} onChange={onVerseNumbers} />
</div> </div>
<div className={row}> <div className={row}>
<span className={label}>Width</span> <span className={label}>Width</span>
<Segmented options={[[false, 'Column'], [true, 'Two columns']]} value={wide} onChange={onWide} /> <Segmented label="Reading width" options={[[false, 'Column'], [true, 'Two columns']]} value={wide} onChange={onWide} />
</div>
<div className={row}>
<span className={label}>Read beside another Bible</span>
<div role="group" aria-label="Second translation" className="flex flex-wrap gap-1.5">
{[{ id: '', name: 'Off' }, ...READER_TRANSLATIONS.filter((t) => t.id !== translation)].map((t) => (
<button key={t.id || 'off'} type="button" onClick={() => onParallel(t.id)} aria-pressed={parallel === t.id} title={t.name}
className={`rounded-full border px-3 py-1 text-xs font-medium ${parallel === t.id ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
{t.id || 'Off'}
</button>
))}
</div>
<p className="mt-1.5 text-[11px] text-[var(--r-sub)]">Shows both side by side on wider screens, and stacked on a phone.</p>
</div> </div>
</Sheet> </Sheet>
); );
+37 -3
View File
@@ -2,17 +2,51 @@
// close on outside tap; non-modal ones (the verse sheet) leave the text // close on outside tap; non-modal ones (the verse sheet) leave the text
// tappable. With `pane`, screens >= lg show it as a right-hand side pane // tappable. With `pane`, screens >= lg show it as a right-hand side pane
// instead of a bottom sheet (tablet landscape / desktop). // instead of a bottom sheet (tablet landscape / desktop).
import { useEffect, useRef } from 'react';
import { CloseIcon } from './icons.jsx'; import { CloseIcon } from './icons.jsx';
export default function Sheet({ open, onClose, title, headerRight, modal = true, pane = false, children }) { const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
export default function Sheet({ open, onClose, title, label, headerRight, modal = true, pane = false, children }) {
const dialogRef = useRef(null);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
// Modal sheets behave like dialogs: focus moves in, Tab stays inside, Escape closes, and focus
// returns to whatever opened the sheet. (The verse pane is not modal, so it leaves focus alone.)
useEffect(() => {
if (!open || !modal) return undefined;
const opener = document.activeElement;
const dialog = dialogRef.current;
if (dialog && !dialog.contains(document.activeElement)) dialog.focus({ preventScroll: true });
const onKey = (e) => {
if (e.key === 'Escape') { e.stopPropagation(); onCloseRef.current?.(); return; }
if (e.key !== 'Tab' || !dialog) return;
const items = [...dialog.querySelectorAll(FOCUSABLE)].filter((el) => el.offsetParent !== null || el === document.activeElement);
if (items.length === 0) { e.preventDefault(); return; }
const first = items[0];
const last = items[items.length - 1];
if (e.shiftKey && (document.activeElement === first || document.activeElement === dialog)) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
};
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('keydown', onKey);
if (opener instanceof HTMLElement && document.contains(opener)) opener.focus({ preventScroll: true });
};
}, [open, modal]);
if (!open) return null; if (!open) return null;
return ( return (
<> <>
{modal && <div className="fixed inset-0 z-40 bg-black/30" onClick={onClose} aria-hidden="true" />} {modal && <div className="fixed inset-0 z-40 bg-black/30" onClick={onClose} aria-hidden="true" />}
<div <div
ref={dialogRef}
role="dialog" role="dialog"
aria-label={typeof title === 'string' ? title : undefined} aria-modal={modal ? 'true' : undefined}
className={`fixed inset-x-0 bottom-0 z-50 flex max-h-[72vh] flex-col rounded-t-2xl border-t border-[var(--r-line)] bg-[var(--r-bar)] text-[var(--r-text)] font-sans ${ aria-label={typeof title === 'string' ? title : label}
tabIndex={-1}
className={`fixed inset-x-0 bottom-0 z-50 flex max-h-[72vh] flex-col rounded-t-2xl focus:outline-none border-t border-[var(--r-line)] bg-[var(--r-bar)] text-[var(--r-text)] font-sans ${
pane ? 'lg:inset-x-auto lg:bottom-0 lg:right-0 lg:top-12 lg:max-h-none lg:w-[24rem] lg:rounded-none lg:border-l lg:border-t-0' : 'sm:left-1/2 sm:right-auto sm:w-[36rem] sm:-translate-x-1/2' pane ? 'lg:inset-x-auto lg:bottom-0 lg:right-0 lg:top-12 lg:max-h-none lg:w-[24rem] lg:rounded-none lg:border-l lg:border-t-0' : 'sm:left-1/2 sm:right-auto sm:w-[36rem] sm:-translate-x-1/2'
}`} }`}
> >
+41 -3
View File
@@ -5,8 +5,9 @@ import Sheet from './Sheet.jsx';
import { CommentaryTab, PeoplePlacesTab } from '../StudyPanel.jsx'; import { CommentaryTab, PeoplePlacesTab } from '../StudyPanel.jsx';
import { BookmarkIcon, CopyIcon } from '../../utils/bibleUtils.jsx'; import { BookmarkIcon, CopyIcon } from '../../utils/bibleUtils.jsx';
import { formatCrossRef, speakOriginalWord } from '../../utils/wordStudy.js'; import { formatCrossRef, speakOriginalWord } from '../../utils/wordStudy.js';
import { READER_TRANSLATIONS, fetchChapter } from '../../utils/bibleText.js';
const TABS = [['commentary', 'Commentary'], ['original', 'Original'], ['refs', 'Cross-refs'], ['people', 'People & places']]; const TABS = [['commentary', 'Commentary'], ['compare', 'Compare'], ['original', 'Original'], ['refs', 'Cross-refs'], ['people', 'People & places']];
function OriginalTab({ words }) { function OriginalTab({ words }) {
if (!words) return <p className="text-sm text-[var(--r-sub)]">No original-language data for this verse.</p>; if (!words) return <p className="text-sm text-[var(--r-sub)]">No original-language data for this verse.</p>;
@@ -49,6 +50,42 @@ function RefsTab({ refs, loading, onJump }) {
); );
} }
// This verse in every translation, one under the other. Each Bible loads the chapter it is in
// (BSB and NET come from the free source; the rest through the server) and shows this verse.
function CompareTab({ book, chapter, verse, current }) {
const [rows, setRows] = useState({}); // id -> { text } | { error } | undefined while loading
useEffect(() => {
const controller = new AbortController();
setRows({});
READER_TRANSLATIONS.forEach((t) => {
fetchChapter(t.id, book, chapter, { signal: controller.signal })
.then(({ verses }) => {
const text = verses.find((v) => v.number === verse)?.text;
setRows((r) => ({ ...r, [t.id]: text ? { text } : { error: 'No verse here in this translation.' } }));
})
.catch((err) => { if (!controller.signal.aborted) setRows((r) => ({ ...r, [t.id]: { error: err.message || 'Unavailable.' } })); });
});
return () => controller.abort();
}, [book, chapter, verse]);
return (
<div className="space-y-3">
{READER_TRANSLATIONS.map((t) => {
const row = rows[t.id];
return (
<div key={t.id}>
<p className="mb-0.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">{t.id}{t.id === current ? ' · reading' : ''}</p>
{!row ? <p className="text-sm text-[var(--r-sub)]">Loading…</p>
: row.error ? <p className="text-sm text-[var(--r-sub)]">{row.error}</p>
: <p className="font-serif text-[15px] leading-relaxed">{row.text}</p>}
</div>
);
})}
<p className="border-t border-[var(--r-line)] pt-2 text-[10px] leading-snug text-[var(--r-sub)]">Copyright notices for each translation appear at the foot of the chapter when you read it.</p>
</div>
);
}
export default function VerseSheet({ export default function VerseSheet({
verse, book, bookName, chapter, translation, words, crossRefs, crossRefsLoading, onLoadCrossRefs, verse, book, bookName, chapter, translation, words, crossRefs, crossRefsLoading, onLoadCrossRefs,
bookmarkColor, onToggleBookmark, onCycleColor, onJump, onClose, bookmarkColor, onToggleBookmark, onCycleColor, onJump, onClose,
@@ -86,15 +123,16 @@ export default function VerseSheet({
</> </>
)} )}
> >
<div className="mb-3 flex gap-1.5 overflow-x-auto pb-1"> <div role="tablist" aria-label="About this verse" className="mb-3 flex gap-1.5 overflow-x-auto pb-1">
{TABS.map(([id, label]) => ( {TABS.map(([id, label]) => (
<button key={id} type="button" onClick={() => setTab(id)} <button key={id} type="button" role="tab" aria-selected={tab === id} onClick={() => setTab(id)}
className={`shrink-0 rounded-full border px-3 py-1 text-xs ${tab === id ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}> className={`shrink-0 rounded-full border px-3 py-1 text-xs ${tab === id ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
{label} {label}
</button> </button>
))} ))}
</div> </div>
{tab === 'commentary' && <CommentaryTab book={book} chapter={chapter} verse={verse.number} />} {tab === 'commentary' && <CommentaryTab book={book} chapter={chapter} verse={verse.number} />}
{tab === 'compare' && <CompareTab book={book} chapter={chapter} verse={verse.number} current={translation} />}
{tab === 'original' && <OriginalTab words={words} />} {tab === 'original' && <OriginalTab words={words} />}
{tab === 'refs' && <RefsTab refs={crossRefs} loading={crossRefsLoading} onJump={onJump} />} {tab === 'refs' && <RefsTab refs={crossRefs} loading={crossRefsLoading} onJump={onJump} />}
{tab === 'people' && <PeoplePlacesTab book={book} chapter={chapter} verse={verse.number} />} {tab === 'people' && <PeoplePlacesTab book={book} chapter={chapter} verse={verse.number} />}
+3 -3
View File
@@ -3,15 +3,15 @@
export const READER_THEMES = { export const READER_THEMES = {
paper: { paper: {
label: 'Paper', label: 'Paper',
vars: { '--r-bg': '#faf7f0', '--r-text': '#2b2620', '--r-sub': '#7d7466', '--r-accent': '#b4532a', '--r-bar': '#f1ece0', '--r-line': '#e0d8c6', '--r-hl': '#f6e7a8', '--r-on': '#ffffff', '--r-danger': '#b42318' }, vars: { '--r-bg': '#faf7f0', '--r-text': '#2b2620', '--r-sub': '#6b6458', '--r-accent': '#a84e27', '--r-bar': '#f1ece0', '--r-line': '#e0d8c6', '--r-hl': '#f6e7a8', '--r-on': '#ffffff', '--r-danger': '#b42318' },
}, },
sepia: { sepia: {
label: 'Sepia', label: 'Sepia',
vars: { '--r-bg': '#f1e4c8', '--r-text': '#3b2f1c', '--r-sub': '#7d6b48', '--r-accent': '#9a4a1e', '--r-bar': '#e8d8b6', '--r-line': '#d5c197', '--r-hl': '#e9cf85', '--r-on': '#ffffff', '--r-danger': '#a1281d' }, vars: { '--r-bg': '#f1e4c8', '--r-text': '#3b2f1c', '--r-sub': '#635539', '--r-accent': '#93471d', '--r-bar': '#e8d8b6', '--r-line': '#d5c197', '--r-hl': '#e9cf85', '--r-on': '#ffffff', '--r-danger': '#a1281d' },
}, },
dark: { dark: {
label: 'Dark', label: 'Dark',
vars: { '--r-bg': '#1c1b19', '--r-text': '#e6e1d6', '--r-sub': '#9a9385', '--r-accent': '#e0895c', '--r-bar': '#262421', '--r-line': '#3a3733', '--r-hl': '#4a4224', '--r-on': '#1c1b19', '--r-danger': '#f0857a' }, vars: { '--r-bg': '#1c1b19', '--r-text': '#e6e1d6', '--r-sub': '#b8b2a4', '--r-accent': '#e0895c', '--r-bar': '#262421', '--r-line': '#3a3733', '--r-hl': '#4a4224', '--r-on': '#1c1b19', '--r-danger': '#f0857a' },
}, },
}; };
@@ -0,0 +1,27 @@
import { describe, expect, test } from 'vitest';
import { READER_THEMES } from './readerThemes.js';
// WCAG relative luminance and contrast ratio
const luminance = (hex) => {
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const contrast = (a, b) => {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
};
// Small text needs 4.5:1. These are the pairings the app actually draws.
describe.each(Object.entries(READER_THEMES))('%s theme contrast', (_id, { vars }) => {
const v = (name) => vars[`--r-${name}`];
test.each([
['text', 'bg'], ['text', 'bar'], ['text', 'hl'],
['sub', 'bg'], ['sub', 'bar'], ['sub', 'hl'],
['accent', 'bg'], ['accent', 'bar'],
['danger', 'bg'], ['danger', 'bar'],
['on', 'accent'],
])('%s on %s is at least 4.5:1', (fg, bg) => {
expect(contrast(v(fg), v(bg))).toBeGreaterThanOrEqual(4.5);
});
});
+113
View File
@@ -0,0 +1,113 @@
// Settings → "Your data": download everything as one file, restore from one, and save Bibles for offline.
import { useRef, useState } from 'react';
import { useApp } from '../../context/AppContext.js';
import { useToast } from '../Toasts.jsx';
import { DownloadProgress } from '../OfflineSave.jsx';
import { useOfflineSave } from '../../hooks/useOfflineSave.js';
import { backupFileName, buildBackup, restoreProjects, validateBackup } from '../../utils/backup.js';
import { saveRemoteProject } from '../../syncService.js';
const btn = 'rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5 disabled:opacity-50';
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
export default function DataSection() {
const {
authUser, readerBookmarks, readerTextHighlights, readerColorLabels, readerInkByPage, readingPlansState,
importReaderMarks, importReadingPlans, refreshProjectIndex,
} = useApp();
const { toast } = useToast();
const offline = useOfflineSave();
const fileRef = useRef(null);
const [pending, setPending] = useState(null); // { name, backup, summary } waiting for "Restore"
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const download = () => {
const backup = buildBackup({
email: authUser?.email ?? null,
reader: { bookmarks: readerBookmarks, highlights: readerTextHighlights, colorLabels: readerColorLabels, ink: readerInkByPage },
readingPlans: readingPlansState,
});
const url = URL.createObjectURL(new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' }));
const link = document.createElement('a');
link.href = url;
link.download = backupFileName();
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
toast(`Backup downloaded: ${plural(backup.projects.length, 'study', 'studies')} and your reading marks`);
};
const choose = async (e) => {
const file = e.target.files?.[0];
e.target.value = '';
setError('');
setPending(null);
if (!file) return;
let data;
try { data = JSON.parse(await file.text()); } catch { setError('That file couldn’t be read as a backup.'); return; }
const checked = validateBackup(data);
if (!checked.ok) { setError(checked.error); return; }
setPending({ name: file.name, ...checked });
};
const restore = async () => {
setBusy(true);
const { backup } = pending;
const written = restoreProjects(backup.projects);
importReaderMarks(backup.reader);
importReadingPlans(backup.readingPlans);
refreshProjectIndex();
// Studies restored here also go to the account, so they follow you to other devices
if (authUser) await Promise.all(written.map((p) => saveRemoteProject(p)));
setBusy(false);
setPending(null);
toast(`Restored ${plural(written.length, 'study', 'studies')}, plus your bookmarks, highlights and plans`);
};
const s = pending?.summary;
return (
<section id="backup" aria-label="Your data" className="scroll-mt-16 space-y-5 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6">
<div>
<h2 className="text-base font-medium">Your data</h2>
<p className="mt-1 text-sm text-[var(--r-sub)]">Everything you’ve made — studies and notes, bookmarks, highlights, ink and reading plans — in one file you keep.</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<button type="button" onClick={download} className={btn}>Download backup</button>
<button type="button" onClick={() => fileRef.current?.click()} className={btn}>Restore from a backup…</button>
<input ref={fileRef} type="file" accept="application/json,.json" onChange={choose} className="hidden" aria-label="Backup file" />
</div>
{error && <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{error}</p>}
{pending && (
<div className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-4 text-sm">
<p className="font-medium">{pending.name}</p>
<p className="mt-1 text-[var(--r-sub)]">
Contains {plural(s.projects, 'study', 'studies')}, {plural(s.bookmarks, 'bookmark')}, {plural(s.highlights, 'highlight')}, {plural(s.inkPages, 'ink page')} and {plural(s.plans, 'reading plan')}.
Restoring adds these to what you have — nothing is deleted, and a study on this device is only replaced if the backup’s copy is newer.
</p>
<div className="mt-3 flex gap-2">
<button type="button" onClick={restore} disabled={busy} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50">{busy ? 'Restoring…' : 'Restore'}</button>
<button type="button" onClick={() => setPending(null)} className={btn}>Cancel</button>
</div>
</div>
)}
<div className="border-t border-[var(--r-line)] pt-4">
<h3 className="text-sm font-medium">Read offline</h3>
<p className="mb-3 mt-1 text-sm text-[var(--r-sub)]">Chapters you read are kept automatically. To read anywhere, save a whole Bible while you’re connected (about 4 MB). The ESV, NLT, CSB and NASB can’t be saved: their licences don’t allow it.</p>
{offline.job ? <DownloadProgress job={offline.job} onCancel={offline.cancel} /> : (
<div className="flex flex-wrap gap-3">
{['BSB', 'NET'].map((t) => (
<button key={t} type="button" onClick={() => offline.saveBible(t)} className={btn}>
{(offline.saved[t] ?? []).length >= 66 ? `✓ The whole ${t} is saved` : `Save the whole ${t}`}
</button>
))}
</div>
)}
</div>
</section>
);
}
+1 -1
View File
@@ -8,7 +8,7 @@ export default function OutlineRail({ project, selectedId, onSelect, onEditOutli
return ( return (
<nav aria-label="Chunks" className="flex h-full min-h-0 flex-col"> <nav aria-label="Chunks" className="flex h-full min-h-0 flex-col">
<div className="flex items-center justify-between px-3 pb-2 pt-3"> <div className="flex items-center justify-between px-3 pb-2 pt-3">
<span className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Outline</span> <span className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Chunks</span>
<button type="button" onClick={onEditOutline} className="text-[11px] text-[var(--r-accent)] hover:underline">Edit</button> <button type="button" onClick={onEditOutline} className="text-[11px] text-[var(--r-accent)] hover:underline">Edit</button>
</div> </div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-4"> <div className="min-h-0 flex-1 overflow-y-auto px-2 pb-4">
+23 -9
View File
@@ -3,12 +3,13 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useApp } from '../../context/AppContext.js'; import { useApp } from '../../context/AppContext.js';
import { themeVars, READER_THEMES } from '../reader/readerThemes.js'; import { themeVars, READER_THEMES } from '../reader/readerThemes.js';
import { ChevronLeft } from '../reader/icons.jsx'; import { ChevronLeft, SearchIcon } from '../reader/icons.jsx';
// `short` is what a phone shows; the full label is still the button's accessible name.
const NAV = [ const NAV = [
{ id: 'studies', label: 'Studies' }, { id: 'studies', label: 'Studies', short: 'Studies' },
{ id: 'reader', label: 'Read Bible' }, { id: 'reader', label: 'Read Bible', short: 'Bible' },
{ id: 'plans', label: 'Reading Plans' }, { id: 'plans', label: 'Reading Plans', short: 'Plans' },
]; ];
// Avatar button with a dropdown: who you are, the theme, Settings, Admin, Log out. // Avatar button with a dropdown: who you are, the theme, Settings, Admin, Log out.
@@ -61,25 +62,37 @@ function AccountMenu() {
} }
export default function PageShell({ title, onBack, actions, active = null, maxWidth = 'max-w-6xl', children }) { export default function PageShell({ title, onBack, actions, active = null, maxWidth = 'max-w-6xl', children }) {
const { readerTheme, goHome, goStudies, openBibleReader, openReadingPlans } = useApp(); const { readerTheme, goHome, goStudies, openBibleReader, openReadingPlans, openPalette } = useApp();
const go = { studies: goStudies, reader: openBibleReader, plans: openReadingPlans }; const go = { studies: goStudies, reader: openBibleReader, plans: openReadingPlans };
return ( return (
<div className="min-h-dvh bg-[var(--r-bg)] font-sans text-[var(--r-text)]" style={themeVars(readerTheme)}> <div className="min-h-dvh bg-[var(--r-bg)] font-sans text-[var(--r-text)]" style={themeVars(readerTheme)}>
<a href="#content" className="skip-link">Skip to content</a>
<header className="safe-top sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)]"> <header className="safe-top sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)]">
<div className={`mx-auto flex h-12 ${maxWidth} items-center gap-4 px-4`}> <div className={`mx-auto flex h-12 ${maxWidth} items-center gap-2 px-3 sm:gap-4 sm:px-4`}>
<button type="button" onClick={goHome} className="flex shrink-0 items-center gap-2" aria-label="Bible Study home"> <button type="button" onClick={goHome} className="flex shrink-0 items-center gap-2" aria-label="Bible Study home">
<img src="/logo.svg" alt="" className="h-6 w-6" /> <img src="/logo.svg" alt="" className="h-6 w-6" />
<span className="hidden text-sm font-medium sm:inline">Bible Study</span> <span className="hidden text-sm font-medium sm:inline">Bible Study</span>
</button> </button>
<nav aria-label="Main" className="flex items-center gap-0.5"> <nav aria-label="Main" className="flex items-center gap-0.5">
{NAV.map((n) => ( {NAV.map((n) => (
<button key={n.id} type="button" onClick={go[n.id]} aria-current={active === n.id ? 'page' : undefined} <button key={n.id} type="button" onClick={go[n.id]} aria-current={active === n.id ? 'page' : undefined} aria-label={n.label}
className={`rounded-full px-3 py-1.5 text-sm ${active === n.id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{n.label}</button> className={`whitespace-nowrap rounded-full px-2.5 py-1.5 text-sm sm:px-3 ${active === n.id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>
<span className="sm:hidden" aria-hidden="true">{n.short}</span><span className="hidden sm:inline" aria-hidden="true">{n.label}</span>
</button>
))} ))}
</nav> </nav>
<div className="ml-auto"><AccountMenu /></div> <div className="ml-auto flex items-center gap-2">
{openPalette && (
<button type="button" onClick={openPalette} aria-label="Search and go to"
className="flex items-center gap-2 rounded-full border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-1 text-xs text-[var(--r-sub)] hover:bg-black/5">
<SearchIcon /><span className="hidden sm:inline">Search</span><kbd className="hidden rounded border border-[var(--r-line)] px-1 text-[10px] md:inline">⌘K</kbd>
</button>
)}
<AccountMenu />
</div>
</div> </div>
</header> </header>
<div id="content" tabIndex={-1} className="focus:outline-none">
<div className={`mx-auto flex ${maxWidth} flex-wrap items-center gap-3 px-4 pt-6`}> <div className={`mx-auto flex ${maxWidth} flex-wrap items-center gap-3 px-4 pt-6`}>
{onBack && ( {onBack && (
<button type="button" onClick={onBack} aria-label="Back" className="-ml-2 flex h-9 w-9 items-center justify-center rounded-lg text-[var(--r-sub)] hover:bg-black/5"><ChevronLeft /></button> <button type="button" onClick={onBack} aria-label="Back" className="-ml-2 flex h-9 w-9 items-center justify-center rounded-lg text-[var(--r-sub)] hover:bg-black/5"><ChevronLeft /></button>
@@ -88,6 +101,7 @@ export default function PageShell({ title, onBack, actions, active = null, maxWi
{actions && <div className="ml-auto flex flex-wrap items-center gap-2">{actions}</div>} {actions && <div className="ml-auto flex flex-wrap items-center gap-2">{actions}</div>}
</div> </div>
{children} {children}
</div>
</div> </div>
); );
} }
+13 -3
View File
@@ -6,8 +6,9 @@ import { themeVars, READER_THEMES } from '../reader/readerThemes.js';
import { ChevronLeft } from '../reader/icons.jsx'; import { ChevronLeft } from '../reader/icons.jsx';
export const STAGES = [ export const STAGES = [
{ id: 'setup', label: 'Outline' }, { id: 'setup', label: 'Chunks' },
{ id: 'study', label: 'Study' }, { id: 'study', label: 'Study' },
{ id: 'outline', label: 'Outline' },
{ id: 'script', label: 'Script' }, { id: 'script', label: 'Script' },
{ id: 'export', label: 'Export' }, { id: 'export', label: 'Export' },
]; ];
@@ -16,7 +17,7 @@ const THEME_ORDER = ['paper', 'sepia', 'dark'];
export default function StudioShell({ stage, loading = false, fixedHeight = false, children }) { export default function StudioShell({ stage, loading = false, fixedHeight = false, children }) {
const { const {
goHome, setCurrentPage, project, allChunks, goHome, setCurrentPage, project, allChunks,
saveStatus, syncStatus, retrySync, saveStatus, syncStatus, retrySync, syncConflict, keepMyVersion, useServerVersion,
readerTheme, setReaderTheme, readerTheme, setReaderTheme,
} = useApp(); } = useApp();
const noChunks = allChunks.length === 0; const noChunks = allChunks.length === 0;
@@ -34,7 +35,7 @@ export default function StudioShell({ stage, loading = false, fixedHeight = fals
const disabled = s.id !== 'setup' && (!project || noChunks); const disabled = s.id !== 'setup' && (!project || noChunks);
return ( return (
<button key={s.id} type="button" disabled={disabled} aria-current={stage === s.id ? 'step' : undefined} <button key={s.id} type="button" disabled={disabled} aria-current={stage === s.id ? 'step' : undefined}
title={disabled ? 'Add a chunk in Outline first' : undefined} title={disabled ? 'Add a chunk in Chunks first' : undefined}
onClick={() => setCurrentPage(s.id)} onClick={() => setCurrentPage(s.id)}
className={`rounded-full px-3.5 py-1.5 text-xs ${stage === s.id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'} disabled:opacity-40 disabled:hover:bg-transparent`}> className={`rounded-full px-3.5 py-1.5 text-xs ${stage === s.id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'} disabled:opacity-40 disabled:hover:bg-transparent`}>
{s.label} {s.label}
@@ -54,6 +55,15 @@ export default function StudioShell({ stage, loading = false, fixedHeight = fals
</div> </div>
</div> </div>
</div> </div>
{syncConflict && project && syncConflict.id === project.id && (
<div role="alert" className="flex shrink-0 flex-wrap items-center justify-center gap-x-4 gap-y-2 border-b border-[var(--r-line)] bg-[var(--r-hl)] px-4 py-2.5 text-sm">
<span>This study was changed on another device. Your edits here are saved on this device, but not yet synced.</span>
<span className="flex gap-2">
<button type="button" onClick={keepMyVersion} className="rounded-full bg-[var(--r-text)] px-3 py-1 text-xs font-medium text-[var(--r-bg)] hover:opacity-90">Keep this version</button>
<button type="button" onClick={useServerVersion} className="rounded-full border border-[var(--r-text)] px-3 py-1 text-xs font-medium hover:bg-black/5">Use the other version</button>
</span>
</div>
)}
{children} {children}
</div> </div>
); );
+104 -35
View File
@@ -6,21 +6,29 @@ import { bookOptions, CHAPTER_COUNTS, parseBibleChapter } from '../utils/bibleUt
import { useReaderMarksSync } from './useReaderMarksSync.js'; import { useReaderMarksSync } from './useReaderMarksSync.js';
import { usePrefsSync } from './usePrefsSync.js'; import { usePrefsSync } from './usePrefsSync.js';
import { COMMENTARIES } from './useVerseStudy.js'; import { COMMENTARIES } from './useVerseStudy.js';
import { READER_TRANSLATIONS, fetchChapter } from '../utils/bibleText.js';
import { mergeMarks, normalizeMarks } from '../utils/readerMarks.js';
import { BIBLE_IDB_TTL, getBibleIndexFromIDB, saveBibleIndexToIDB } from '../utils/bibleIndexCache.js'; import { BIBLE_IDB_TTL, getBibleIndexFromIDB, saveBibleIndexToIDB } from '../utils/bibleIndexCache.js';
export const READER_TRANSLATIONS = [ export { READER_TRANSLATIONS };
{ id: 'BSB', name: 'Berean Standard Bible' },
{ id: 'ESV', name: 'English Standard Version', licensed: true, // A per-device choice kept in localStorage: useStoredChoice('reader-font', 'serif', ['serif', 'sans']).
notice: 'Scripture quotations are from the ESV® Bible (The Holy Bible, English Standard Version®), © 2001 by Crossway, a publishing ministry of Good News Publishers. Used by permission. All rights reserved.' }, function useStoredChoice(key, fallback, allowed) {
{ id: 'NET', name: 'NET Bible', helloao: 'eng_net', const [value, setValue] = useState(() => {
notice: 'Scripture quoted by permission. Quotations designated (NET) are from the NET Bible® copyright ©1996-2017 by Biblical Studies Press, L.L.C. http://netbible.com All rights reserved.' }, try {
{ id: 'NLT', name: 'New Living Translation', licensed: true, const saved = localStorage.getItem(key);
notice: 'Holy Bible, New Living Translation, Copyright © 2014, Tyndale House Publishers. All rights reserved. tyndale.com' }, return saved !== null && allowed.includes(saved) ? saved : fallback;
{ id: 'CSB', name: 'Christian Standard Bible', licensed: true, } catch { return fallback; }
notice: 'Christian Standard Bible® and CSB® are federally registered trademarks of Holman Bible Publishers. All rights reserved. bhpublishinggroup.com' }, });
{ id: 'NASB', name: 'New American Standard Bible', licensed: true, const set = (next) => {
notice: 'New American Standard Bible®, Copyright © 1995, The Lockman Foundation. All rights reserved. lockman.org' }, setValue(next);
]; try { localStorage.setItem(key, next); } catch {}
};
return [value, set];
}
export const READER_FONTS = ['serif', 'sans', 'easy'];
export const READER_SPACINGS = ['snug', 'normal', 'airy'];
const BOOKMARK_COLORS = ['#fef08a', '#bbf7d0', '#bfdbfe', '#fecaca', '#e9d5ff']; const BOOKMARK_COLORS = ['#fef08a', '#bbf7d0', '#bfdbfe', '#fecaca', '#e9d5ff'];
@@ -91,6 +99,11 @@ export function useBibleReader(currentPage, translationOverride = null) {
setReaderWideState(!!on); setReaderWideState(!!on);
try { localStorage.setItem('reader-wide', on ? '1' : '0'); } catch {} try { localStorage.setItem('reader-wide', on ? '1' : '0'); } catch {}
}; };
// Typography and a second Bible shown beside the first — all per-device, like text size.
const [readerFont, setReaderFont] = useStoredChoice('reader-font', 'serif', READER_FONTS);
const [readerSpacing, setReaderSpacing] = useStoredChoice('reader-spacing', 'normal', READER_SPACINGS);
const [readerVerseNumbers, setReaderVerseNumbersRaw] = useStoredChoice('reader-verse-numbers', 'on', ['on', 'off']);
const [readerParallel, setReaderParallel] = useStoredChoice('reader-parallel', '', ['', ...READER_TRANSLATIONS.map((t) => t.id)]);
// The page the app opens to, and where every Home button goes: 'studies' | 'reader' | 'plans' // The page the app opens to, and where every Home button goes: 'studies' | 'reader' | 'plans'
const [startPagePref, setStartPagePrefState] = useState(() => { const [startPagePref, setStartPagePrefState] = useState(() => {
try { try {
@@ -136,6 +149,21 @@ export function useBibleReader(currentPage, translationOverride = null) {
return Array.isArray(val) ? val : []; return Array.isArray(val) ? val : [];
} catch { return []; } } catch { return []; }
}); });
// What each highlight colour means to this reader ({ '#fef08a': 'Promises' }); follows the account.
const [readerColorLabels, setReaderColorLabelsState] = useState(() => {
try {
const val = JSON.parse(localStorage.getItem('reader-color-labels') || '{}');
return val && typeof val === 'object' && !Array.isArray(val) ? val : {};
} catch { return {}; }
});
const setReaderColorLabel = (color, label) => {
setReaderColorLabelsState((prev) => {
const next = { ...prev };
if (label.trim()) next[color] = label.slice(0, 30); else delete next[color];
try { localStorage.setItem('reader-color-labels', JSON.stringify(next)); } catch {}
return next;
});
};
// Debounce timer ref so rapid stroke updates don't flood the server // Debounce timer ref so rapid stroke updates don't flood the server
const inkSaveTimerRef = useRef({}); const inkSaveTimerRef = useRef({});
@@ -210,38 +238,25 @@ export function useBibleReader(currentPage, translationOverride = null) {
// BSB comes straight from HelloAO; licensed translations (NLT/CSB/NASB) go // BSB comes straight from HelloAO; licensed translations (NLT/CSB/NASB) go
// through the server's api.bible proxy so the API key never reaches the browser. // through the server's api.bible proxy so the API key never reaches the browser.
// The most recent request wins: flipping chapters quickly (or changing translation) must never let a
// slow earlier response replace the page you are now on.
const chapterRequestRef = useRef(0);
const loadReaderChapter = async (bookAbbrev, chapterNumber, translation = readerTranslation) => { const loadReaderChapter = async (bookAbbrev, chapterNumber, translation = readerTranslation) => {
const requestId = ++chapterRequestRef.current;
setReaderLoading(true); setReaderLoading(true);
setReaderError(''); setReaderError('');
try { try {
let verses; const { verses, copyright, totalChapters } = await fetchChapter(translation, bookAbbrev, chapterNumber);
let copyright = ''; if (requestId !== chapterRequestRef.current) return;
let totalChapters = CHAPTER_COUNTS[bookAbbrev] ?? chapterNumber;
const free = READER_TRANSLATIONS.find((t) => t.id === translation && !t.licensed);
if (free) {
const res = await fetch(`https://bible.helloao.org/api/${free.helloao ?? free.id}/${bookAbbrev}/${chapterNumber}.json`);
if (!res.ok) throw new Error('Unable to load chapter.');
const data = await res.json();
verses = parseBibleChapter(data);
totalChapters = data.book?.numberOfChapters ?? totalChapters;
} else {
const res = await fetch(`/api/bible/${translation}/${bookAbbrev}/${chapterNumber}`, { credentials: 'include' });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Unable to load ${translation}.`);
verses = data.verses;
copyright = data.copyright ?? '';
}
if (!Array.isArray(verses) || verses.length === 0) {
throw new Error('Invalid Bible data returned.');
}
setReaderVerses(verses); setReaderVerses(verses);
setReaderCopyright(copyright); setReaderCopyright(copyright);
setReaderTotalChapters(totalChapters); setReaderTotalChapters(totalChapters);
} catch (err) { } catch (err) {
if (requestId !== chapterRequestRef.current) return;
setReaderError(err.message || 'Failed to load chapter.'); setReaderError(err.message || 'Failed to load chapter.');
setReaderVerses([]); setReaderVerses([]);
} finally { } finally {
setReaderLoading(false); if (requestId === chapterRequestRef.current) setReaderLoading(false);
} }
}; };
@@ -256,7 +271,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
if (readerJumpVerse == null || readerLoading) return; if (readerJumpVerse == null || readerLoading) return;
const el = document.getElementById(`reader-verse-${readerJumpVerse}`); const el = document.getElementById(`reader-verse-${readerJumpVerse}`);
if (!el) return; if (!el) return;
el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.scrollIntoView({ behavior: window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth', block: 'center' });
const prevTransition = el.style.transition; const prevTransition = el.style.transition;
const prevBackground = el.style.backgroundColor; const prevBackground = el.style.backgroundColor;
el.style.transition = 'background-color 0.4s'; el.style.transition = 'background-color 0.4s';
@@ -347,6 +362,16 @@ export function useBibleReader(currentPage, translationOverride = null) {
}); });
}; };
// Puts a removed bookmark back with its colour (used by "Undo").
const restoreReaderBookmark = (verseKey, color) => {
setReaderBookmarks((prev) => {
if (prev[verseKey]) return prev;
const next = { ...prev, [verseKey]: color };
try { localStorage.setItem('reader-bookmarks', JSON.stringify(next)); } catch {}
return next;
});
};
const updateReaderPageInk = useCallback((bookAbbrev, chapter, strokes) => { const updateReaderPageInk = useCallback((bookAbbrev, chapter, strokes) => {
const key = `${bookAbbrev}_${chapter}`; const key = `${bookAbbrev}_${chapter}`;
setReaderInkByPage((prev) => { setReaderInkByPage((prev) => {
@@ -385,6 +410,16 @@ export function useBibleReader(currentPage, translationOverride = null) {
}); });
}; };
// Puts a removed highlight back exactly as it was (used by "Undo").
const restoreTextHighlight = (highlight) => {
setReaderTextHighlights((prev) => {
if (prev.some((h) => h.id === highlight.id)) return prev;
const next = [...prev, highlight];
try { localStorage.setItem('readerTextHighlights', JSON.stringify(next)); } catch {}
return next;
});
};
const removeTextHighlight = (id) => { const removeTextHighlight = (id) => {
setReaderTextHighlights(prev => { setReaderTextHighlights(prev => {
const next = prev.filter(h => h.id !== id); const next = prev.filter(h => h.id !== id);
@@ -417,6 +452,30 @@ export function useBibleReader(currentPage, translationOverride = null) {
} }
}; };
// Bookmarks, highlights, colour names and ink from a backup file are added to what is here (nothing is
// removed, and where both have something the copy already here wins).
const importReaderMarks = ({ bookmarks, highlights, colorLabels, ink }) => {
const merged = mergeMarks(normalizeMarks({ bookmarks, highlights }), normalizeMarks({ bookmarks: readerBookmarks, highlights: readerTextHighlights }));
setReaderBookmarks(merged.bookmarks);
setReaderTextHighlights(merged.highlights);
try {
localStorage.setItem('reader-bookmarks', JSON.stringify(merged.bookmarks));
localStorage.setItem('readerTextHighlights', JSON.stringify(merged.highlights));
} catch {}
if (colorLabels && Object.keys(colorLabels).length > 0) {
setReaderColorLabelsState((prev) => {
const next = { ...colorLabels, ...prev };
try { localStorage.setItem('reader-color-labels', JSON.stringify(next)); } catch {}
return next;
});
}
// Ink goes through the normal save so it also reaches the account
for (const [key, strokes] of Object.entries(ink ?? {})) {
const [book, chapter] = key.split('_');
if (!readerInkByPage[key] && book && chapter && Array.isArray(strokes)) updateReaderPageInk(book, chapter, strokes);
}
};
// Merges reader ink loaded from the server at sign-in; server wins over local. // Merges reader ink loaded from the server at sign-in; server wins over local.
const mergeServerInk = useCallback((serverInk) => { const mergeServerInk = useCallback((serverInk) => {
setReaderInkByPage((local) => { setReaderInkByPage((local) => {
@@ -438,6 +497,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
theme: readerTheme, translation: baseTranslation, layout: readerLayout, commentarySource: readerCommentary, theme: readerTheme, translation: baseTranslation, layout: readerLayout, commentarySource: readerCommentary,
planTranslation: planTranslationPref, startPage: startPagePref, planTranslation: planTranslationPref, startPage: startPagePref,
position: { book: readerBookAbbrev, chapter: readerChapter }, position: { book: readerBookAbbrev, chapter: readerChapter },
colorLabels: readerColorLabels,
}, },
apply: (key, v) => { apply: (key, v) => {
if (key === 'theme') { setReaderThemeState(v); try { localStorage.setItem('reader-theme', v); } catch {} } if (key === 'theme') { setReaderThemeState(v); try { localStorage.setItem('reader-theme', v); } catch {} }
@@ -446,6 +506,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
else if (key === 'startPage') { setStartPagePrefState(v); try { localStorage.setItem('start-page', v); } catch {} } else if (key === 'startPage') { setStartPagePrefState(v); try { localStorage.setItem('start-page', v); } catch {} }
else if (key === 'planTranslation') { setPlanTranslationPrefState(v); try { localStorage.setItem('plan-translation', v); } catch {} } else if (key === 'planTranslation') { setPlanTranslationPrefState(v); try { localStorage.setItem('plan-translation', v); } catch {} }
else if (key === 'commentarySource') { setReaderCommentaryState(v); try { localStorage.setItem('reader-commentary', v); } catch {} } else if (key === 'commentarySource') { setReaderCommentaryState(v); try { localStorage.setItem('reader-commentary', v); } catch {} }
else if (key === 'colorLabels') { setReaderColorLabelsState(v); try { localStorage.setItem('reader-color-labels', JSON.stringify(v)); } catch {} }
else if (key === 'position') { setReaderBookAbbrev(v.book); setReaderChapter(v.chapter); setReaderSelectedVerse(null); } else if (key === 'position') { setReaderBookAbbrev(v.book); setReaderChapter(v.chapter); setReaderSelectedVerse(null); }
}, },
canApplyPosition: () => pageRef.current !== 'reader', canApplyPosition: () => pageRef.current !== 'reader',
@@ -467,6 +528,10 @@ export function useBibleReader(currentPage, translationOverride = null) {
readerSelectedVerse, setReaderSelectedVerse, readerSelectedVerse, setReaderSelectedVerse,
readerFontSize, setReaderFontSize, readerFontSize, setReaderFontSize,
readerWide, setReaderWide, readerWide, setReaderWide,
readerFont, setReaderFont,
readerSpacing, setReaderSpacing,
readerVerseNumbers: readerVerseNumbers === 'on', setReaderVerseNumbers: (on) => setReaderVerseNumbersRaw(on ? 'on' : 'off'),
readerParallel, setReaderParallel,
planTranslationPref, setPlanTranslationPref, planTranslationPref, setPlanTranslationPref,
startPagePref, setStartPagePref, startPagePref, setStartPagePref,
readerBookmarks, setReaderBookmarks, readerBookmarks, setReaderBookmarks,
@@ -480,11 +545,14 @@ export function useBibleReader(currentPage, translationOverride = null) {
readerInkByPage, readerInkByPage,
updateReaderPageInk, updateReaderPageInk,
mergeServerInk, mergeServerInk,
importReaderMarks,
readerTextHighlights, readerTextHighlights,
...marksSync, ...marksSync,
...prefsSync, ...prefsSync,
addTextHighlight, addTextHighlight,
removeTextHighlight, removeTextHighlight,
restoreTextHighlight,
readerColorLabels, setReaderColorLabel,
bibleIndexStatusFor, bibleIndexStatusFor,
loadBibleIndex, loadBibleIndex,
_bibleIndexCacheRef, _bibleIndexCacheRef,
@@ -494,6 +562,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
readerGoToNextChapter, readerGoToNextChapter,
jumpToReaderVerse, jumpToReaderVerse,
toggleReaderBookmark, toggleReaderBookmark,
restoreReaderBookmark,
cycleBookmarkColor, cycleBookmarkColor,
loadReaderCrossRefs, loadReaderCrossRefs,
}; };
+28
View File
@@ -2,6 +2,7 @@
// active chapter tab, and every way of creating chunks (click, shift-click, // active chapter tab, and every way of creating chunks (click, shift-click,
// typed range, bulk ranges, cross-chapter spans), plus reorder and delete. // typed range, bulk ranges, cross-chapter spans), plus reorder and delete.
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useToast } from '../components/Toasts.jsx';
import { bookOptions, CHAPTER_COUNTS, makeId, parseBibleChapter } from '../utils/bibleUtils.jsx'; import { bookOptions, CHAPTER_COUNTS, makeId, parseBibleChapter } from '../utils/bibleUtils.jsx';
// Every chunk starts blank; all note fields exist from the start. // Every chunk starts blank; all note fields exist from the start.
@@ -99,6 +100,7 @@ function initialChapterIndex(project) {
} }
export function useChunkBuilder({ project, setProject, updateProject, allChunks, setCurrentPage }) { export function useChunkBuilder({ project, setProject, updateProject, allChunks, setCurrentPage }) {
const { toast } = useToast();
const [setup, setSetup] = useState(() => initialSetupForm(project)); const [setup, setSetup] = useState(() => initialSetupForm(project));
const [titleEdited, setTitleEdited] = useState(false); const [titleEdited, setTitleEdited] = useState(false);
const [activeChapterIndex, setActiveChapterIndex] = useState(() => initialChapterIndex(project)); const [activeChapterIndex, setActiveChapterIndex] = useState(() => initialChapterIndex(project));
@@ -531,7 +533,33 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
})); }));
}; };
// Deleting a chunk also deletes its notes, so it can be taken back for a few seconds.
const deleteChunk = (chunkId) => { const deleteChunk = (chunkId) => {
let removed = null;
(project?.chapters ?? []).forEach((ch, chapterIndex) => {
const index = ch.chunks.findIndex((c) => c.id === chunkId);
if (index >= 0) removed = { chunk: ch.chunks[index], chapterIndex, index, wasSelected: project.selectedChunkId === chunkId };
});
if (removed) {
toast('Chunk deleted', {
action: {
label: 'Undo',
onClick: () => updateProject((current) => {
if (current.chapters.some((ch) => ch.chunks.some((c) => c.id === chunkId))) return current;
return {
...current,
selectedChunkId: removed.wasSelected ? chunkId : current.selectedChunkId,
chapters: current.chapters.map((ch, i) => {
if (i !== removed.chapterIndex) return ch;
const chunks = [...ch.chunks];
chunks.splice(Math.min(removed.index, chunks.length), 0, removed.chunk);
return { ...ch, chunks };
}),
};
}),
},
});
}
updateProject((current) => { updateProject((current) => {
const newAllChunks = current.chapters const newAllChunks = current.chapters
.flatMap((ch) => ch.chunks) .flatMap((ch) => ch.chunks)
+42
View File
@@ -0,0 +1,42 @@
// Makes the browser's (and phone's) Back/Forward buttons move between the app's pages. The app keeps
// its page in state rather than in the URL, so without this Back would leave the app altogether.
// Each page change adds a history entry; Back/Forward set the page again from that entry.
import { useEffect, useRef } from 'react';
// Pages that only make sense with a study project open.
export const NEEDS_PROJECT = ['study', 'outline', 'script', 'export'];
export function useHistoryNav({ page, setPage, hasProject, onArrive, enabled = true }) {
const shown = useRef(page);
const hasProjectRef = useRef(hasProject);
hasProjectRef.current = hasProject;
const onArriveRef = useRef(onArrive);
onArriveRef.current = onArrive;
// First entry: record where the app started so Back can return to it.
useEffect(() => {
if (!enabled) return;
try { window.history.replaceState({ page }, ''); } catch { /* history unavailable */ }
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// A page change made inside the app adds a history entry.
useEffect(() => {
if (!enabled || shown.current === page) return;
shown.current = page;
try { window.history.pushState({ page }, ''); } catch { /* history unavailable */ }
}, [page, enabled]);
useEffect(() => {
if (!enabled) return undefined;
const onPop = (e) => {
let target = e.state?.page;
if (!target) return; // an entry that isn't ours (e.g. a hash change)
if (NEEDS_PROJECT.includes(target) && !hasProjectRef.current) target = 'home';
shown.current = target; // so the effect above doesn't push this page again
setPage(target);
onArriveRef.current?.(target);
};
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, [enabled, setPage]);
}
+52
View File
@@ -0,0 +1,52 @@
// Drives "Save for offline": one download at a time, with progress for the button to show,
// a cancel, and a toast when it finishes.
import { useCallback, useEffect, useRef, useState } from 'react';
import { useToast } from '../components/Toasts.jsx';
import { bibleRefs, bookRefs, downloadChapters, loadSavedBooks, markBookSaved } from '../utils/offline.js';
export function useOnline() {
const [online, setOnline] = useState(() => (typeof navigator === 'undefined' ? true : navigator.onLine !== false));
useEffect(() => {
const up = () => setOnline(true);
const down = () => setOnline(false);
window.addEventListener('online', up);
window.addEventListener('offline', down);
return () => { window.removeEventListener('online', up); window.removeEventListener('offline', down); };
}, []);
return online;
}
export function useOfflineSave() {
const { toast } = useToast();
const [saved, setSaved] = useState(loadSavedBooks);
const [job, setJob] = useState(null); // { label, done, total } while downloading
const controller = useRef(null);
useEffect(() => () => controller.current?.abort(), []);
const run = useCallback(async (label, translation, refs, { bookKeys = [] } = {}) => {
if (controller.current) return; // one at a time
const abort = new AbortController();
controller.current = abort;
setJob({ label, done: 0, total: refs.length });
const result = await downloadChapters(translation, refs, { signal: abort.signal, onProgress: (done, total) => setJob({ label, done, total }) });
controller.current = null;
setJob(null);
if (abort.signal.aborted) { toast('Download stopped'); return; }
if (result.failed === 0) {
bookKeys.forEach((b) => markBookSaved(translation, b));
setSaved(loadSavedBooks());
toast(`Saved ${label} for offline`);
} else {
toast(`Saved ${result.done} of ${refs.length} chapters — check your connection and try again`, { tone: 'error' });
}
}, [toast]);
return {
saved, job,
isSaved: (translation, book) => (saved[translation] ?? []).includes(book),
saveBook: (translation, book, name) => run(name, translation, bookRefs(book), { bookKeys: [book] }),
saveBible: (translation) => run(`The whole ${translation}`, translation, bibleRefs(), { bookKeys: [...new Set(bibleRefs().map((r) => r.book))] }),
saveRefs: (label, translation, refs) => run(label, translation, refs),
cancel: () => controller.current?.abort(),
};
}
+25
View File
@@ -0,0 +1,25 @@
// The second Bible in the side-by-side view: loads the same chapter in another translation.
// Pass an empty translation to switch it off.
import { useEffect, useState } from 'react';
import { fetchChapter } from '../utils/bibleText.js';
export function useParallelChapter(translation, bookAbbrev, chapter) {
const [state, setState] = useState({ key: '', verses: [], error: '' });
const key = translation ? `${translation}/${bookAbbrev}/${chapter}` : '';
useEffect(() => {
if (!key) return undefined;
const controller = new AbortController();
fetchChapter(translation, bookAbbrev, chapter, { signal: controller.signal })
.then(({ verses }) => setState({ key, verses, error: '' }))
.catch((err) => { if (!controller.signal.aborted) setState({ key, verses: [], error: err.message || 'Unable to load.' }); });
return () => controller.abort();
}, [key]); // eslint-disable-line react-hooks/exhaustive-deps
const ready = state.key === key;
return {
verses: ready ? state.verses : [],
error: ready ? state.error : '',
loading: !!key && !ready,
};
}
+3 -2
View File
@@ -5,7 +5,7 @@
import { useCallback, useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import { getRemotePrefs, saveRemotePrefs } from '../syncService.js'; import { getRemotePrefs, saveRemotePrefs } from '../syncService.js';
export const PREF_KEYS = ['theme', 'translation', 'layout', 'commentarySource', 'planTranslation', 'startPage', 'position']; export const PREF_KEYS = ['theme', 'translation', 'layout', 'commentarySource', 'planTranslation', 'startPage', 'position', 'colorLabels'];
const META_KEY = 'prefs-meta'; // { key: timestamp of the last change made on this device } const META_KEY = 'prefs-meta'; // { key: timestamp of the last change made on this device }
const PUSH_DELAY_MS = 1000; const PUSH_DELAY_MS = 1000;
const FOCUS_PULL_MIN_MS = 30_000; const FOCUS_PULL_MIN_MS = 30_000;
@@ -58,7 +58,7 @@ export function usePrefsSync({ values, apply, canApplyPosition = () => true }) {
window.clearTimeout(timerRef.current); window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(push, PUSH_DELAY_MS); timerRef.current = window.setTimeout(push, PUSH_DELAY_MS);
} }
}, [values.theme, values.translation, values.layout, values.commentarySource, values.planTranslation, values.startPage, values.position.book, values.position.chapter, push]); // eslint-disable-line react-hooks/exhaustive-deps }, [values.theme, values.translation, values.layout, values.commentarySource, values.planTranslation, values.startPage, values.position.book, values.position.chapter, values.colorLabels, push]); // eslint-disable-line react-hooks/exhaustive-deps
const pullServerPrefs = useCallback(async () => { const pullServerPrefs = useCallback(async () => {
enabledRef.current = true; enabledRef.current = true;
@@ -69,6 +69,7 @@ export function usePrefsSync({ values, apply, canApplyPosition = () => true }) {
const meta = readMeta(); const meta = readMeta();
const now = Date.now(); const now = Date.now();
for (const key of PREF_KEYS) { for (const key of PREF_KEYS) {
if (valuesRef.current[key] === undefined) continue; // nothing on this device to compare or upload
const remote = server[key]; const remote = server[key];
const localAt = meta[key] ?? 0; const localAt = meta[key] ?? 0;
if (remote && remote.at > localAt) { if (remote && remote.at > localAt) {
+50 -8
View File
@@ -1,6 +1,8 @@
// The signed-in user's saved projects: opening, renaming, deleting, and keeping // The signed-in user's saved projects: opening, renaming, deleting, and keeping
// this device's copies in step with the server. // this device's copies in step with the server.
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useToast } from '../components/Toasts.jsx';
import { clearSyncBase, getSyncBase, setSyncBase } from '../utils/syncBase.js';
import { loadProjectIndex } from '../utils/bibleUtils.jsx'; import { loadProjectIndex } from '../utils/bibleUtils.jsx';
import { import {
saveProjectToStorage, saveProjectToStorage,
@@ -21,6 +23,9 @@ export function useProjectLibrary({
setProjectIndex, setProjectIndex,
setCurrentPage, setCurrentPage,
}) { }) {
const { toast } = useToast();
// Deleted projects waiting out their "Undo" window before the server copy is removed too
const pendingDeletes = useRef(new Map()); // id -> timer
const [staleLocalProjects, setStaleLocalProjects] = useState([]); // projects where server is newer const [staleLocalProjects, setStaleLocalProjects] = useState([]); // projects where server is newer
const [autoRestoredCount, setAutoRestoredCount] = useState(null); // brief "synced from another device" toast const [autoRestoredCount, setAutoRestoredCount] = useState(null); // brief "synced from another device" toast
@@ -39,11 +44,17 @@ export function useProjectLibrary({
listRemoteProjects().then(async (result) => { listRemoteProjects().then(async (result) => {
if (!result.ok || cancelled) return; if (!result.ok || cancelled) return;
const localMap = new Map(localIndex.map((e) => [e.id, e])); const localMap = new Map(localIndex.map((e) => [e.id, e]));
// What this device has seen of each study on the server: a save made on top of anything older is
// checked against it, so a newer copy from another device is never overwritten silently.
for (const e of result.data) {
const local = localMap.get(e.id);
setSyncBase(e.id, local ? Math.min(e.lastEdited ?? 0, local.lastEdited ?? 0) : e.lastEdited);
}
const missing = result.data.filter((e) => !localMap.has(e.id)); const missing = result.data.filter((e) => !localMap.has(e.id));
if (missing.length > 0) { if (missing.length > 0) {
const fetched = await Promise.all(missing.map((e) => loadRemoteProject(e.id))); const fetched = await Promise.all(missing.map((e) => loadRemoteProject(e.id)));
fetched.filter((r) => r.ok).forEach((r) => saveProjectToStorage(r.data)); fetched.filter((r) => r.ok).forEach((r) => { saveProjectToStorage(r.data); setSyncBase(r.data.id, r.data.lastEdited); });
if (cancelled) return; if (cancelled) return;
const restoredCount = fetched.filter((r) => r.ok).length; const restoredCount = fetched.filter((r) => r.ok).length;
if (restoredCount > 0) { if (restoredCount > 0) {
@@ -78,26 +89,57 @@ export function useProjectLibrary({
const updated = { ...loaded, title, lastEdited: Date.now() }; const updated = { ...loaded, title, lastEdited: Date.now() };
saveProjectToStorage(updated); saveProjectToStorage(updated);
setProjectIndex(loadProjectIndex()); setProjectIndex(loadProjectIndex());
saveRemoteProject(updated); saveRemoteProject(updated, { baseEdited: getSyncBase(id) }).then((result) => {
if (result.ok) setSyncBase(id, updated.lastEdited);
else if (result.status === 409) toast('That study changed on another device. Open it to choose which version to keep.', { tone: 'error' });
});
if (project?.id === id) { if (project?.id === id) {
setProject((current) => (current ? { ...current, title } : current)); setProject((current) => (current ? { ...current, title } : current));
} }
}; };
const deleteProject = (id) => { // Deleting is immediate here, with an Undo for a few seconds; only after that is the server copy
if (!window.confirm('Delete this project? This cannot be undone.')) return; // removed (and straight away if the tab is closed first, so a deleted project never comes back).
deleteProjectFromStorage(id); const finishDelete = (id) => {
setProjectIndex(loadProjectIndex()); if (!pendingDeletes.current.has(id)) return;
window.clearTimeout(pendingDeletes.current.get(id));
pendingDeletes.current.delete(id);
deleteRemoteProject(id); // fire-and-forget deleteRemoteProject(id); // fire-and-forget
}; };
useEffect(() => {
const flush = () => [...pendingDeletes.current.keys()].forEach(finishDelete);
window.addEventListener('pagehide', flush);
return () => window.removeEventListener('pagehide', flush);
}, []);
const deleteProject = (id) => {
const loaded = loadProjectById(id);
if (!loaded) return;
deleteProjectFromStorage(id);
clearSyncBase(id);
setProjectIndex(loadProjectIndex());
pendingDeletes.current.set(id, window.setTimeout(() => finishDelete(id), 8000));
toast(`Deleted “${loaded.title}”`, {
action: {
label: 'Undo',
onClick: () => {
window.clearTimeout(pendingDeletes.current.get(id));
pendingDeletes.current.delete(id);
saveProjectToStorage(loaded);
setProjectIndex(loadProjectIndex());
},
},
});
};
const pullLatestFromServer = async (id) => { const pullLatestFromServer = async (id) => {
const result = await loadRemoteProject(id); const result = await loadRemoteProject(id);
if (!result.ok) { if (!result.ok) {
alert('Could not pull latest version from server.'); toast('Could not pull the latest version from the server.', { tone: 'error' });
return; return;
} }
saveProjectToStorage(result.data); saveProjectToStorage(result.data);
setSyncBase(id, result.data.lastEdited);
setProjectIndex(loadProjectIndex()); setProjectIndex(loadProjectIndex());
setStaleLocalProjects((prev) => prev.filter((e) => e.id !== id)); setStaleLocalProjects((prev) => prev.filter((e) => e.id !== id));
}; };
+41 -3
View File
@@ -6,6 +6,8 @@
// shown, instead of being a fire-and-forget call buried inside setState. // shown, instead of being a fire-and-forget call buried inside setState.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getRemoteReadingProgress, saveRemoteReadingProgress } from '../syncService.js'; import { getRemoteReadingProgress, saveRemoteReadingProgress } from '../syncService.js';
import { useToast } from '../components/Toasts.jsx';
import { mergeReadingPlans } from '../utils/backup.js';
import { clampPlanDays, localDateString, normalizeCustomPlan, registerCustomPlans } from '../utils/readingPlans.js'; import { clampPlanDays, localDateString, normalizeCustomPlan, registerCustomPlans } from '../utils/readingPlans.js';
const STORAGE_KEY = 'readingPlanProgress'; const STORAGE_KEY = 'readingPlanProgress';
@@ -33,7 +35,10 @@ function formatSyncError(result) {
} }
export function useReadingPlans() { export function useReadingPlans() {
const { toast } = useToast();
const [state, setState] = useState(loadLocal); const [state, setState] = useState(loadLocal);
const stateRef = useRef(state); // the latest state, for taking a copy just before something is removed
stateRef.current = state;
const [syncStatus, setSyncStatus] = useState(''); // '' | 'syncing' | 'synced' | 'error' const [syncStatus, setSyncStatus] = useState(''); // '' | 'syncing' | 'synced' | 'error'
const [syncError, setSyncError] = useState(''); const [syncError, setSyncError] = useState('');
// The exact object loadLocal() produced at mount. Compared by reference (not // The exact object loadLocal() produced at mount. Compared by reference (not
@@ -167,17 +172,30 @@ export function useReadingPlans() {
updateProgress((prev) => prev.map((p) => (p.id === progressId ? { ...p, translation } : p))); updateProgress((prev) => prev.map((p) => (p.id === progressId ? { ...p, translation } : p)));
}, [updateProgress]); }, [updateProgress]);
// Restarting and removing are immediate, with an "Undo" toast that puts the plan back exactly as it was.
const restartPlan = useCallback((progressId) => { const restartPlan = useCallback((progressId) => {
const before = stateRef.current.progress.find((p) => p.id === progressId);
updateProgress((prev) => prev.map((p) => ( updateProgress((prev) => prev.map((p) => (
p.id === progressId p.id === progressId
? { ...p, completedDays: [], chaptersRead: {}, completedAt: null, startedAt: Date.now(), lastActivityAt: Date.now() } ? { ...p, completedDays: [], chaptersRead: {}, completedAt: null, startedAt: Date.now(), lastActivityAt: Date.now() }
: p : p
))); )));
}, [updateProgress]); if (before) toast('Plan restarted from day 1', { action: { label: 'Undo', onClick: () => updateProgress((prev) => prev.map((p) => (p.id === progressId ? before : p))) } });
}, [updateProgress, toast]);
const removePlanProgress = useCallback((progressId) => { const removePlanProgress = useCallback((progressId) => {
const index = stateRef.current.progress.findIndex((p) => p.id === progressId);
const before = stateRef.current.progress[index];
updateProgress((prev) => prev.filter((p) => p.id !== progressId)); updateProgress((prev) => prev.filter((p) => p.id !== progressId));
}, [updateProgress]); if (before) {
toast('Plan removed', {
action: {
label: 'Undo',
onClick: () => updateProgress((prev) => (prev.some((p) => p.id === progressId) ? prev : [...prev.slice(0, index), before, ...prev.slice(index)])),
},
});
}
}, [updateProgress, toast]);
// Resets the pace clock so a plan that's fallen behind schedule stops being // Resets the pace clock so a plan that's fallen behind schedule stops being
// flagged as "behind" — doesn't touch which days are actually marked done. // flagged as "behind" — doesn't touch which days are actually marked done.
@@ -212,9 +230,22 @@ export function useReadingPlans() {
}, [updateCustomPlans]); }, [updateCustomPlans]);
const removeCustomPlan = useCallback((planId) => { const removeCustomPlan = useCallback((planId) => {
const plan = stateRef.current.customPlans.find((p) => p.id === planId);
const runs = stateRef.current.progress.filter((p) => p.planId === planId);
updateCustomPlans((prev) => prev.filter((p) => p.id !== planId)); updateCustomPlans((prev) => prev.filter((p) => p.id !== planId));
updateProgress((prev) => prev.filter((p) => p.planId !== planId)); updateProgress((prev) => prev.filter((p) => p.planId !== planId));
}, [updateCustomPlans, updateProgress]); if (plan) {
toast(`Deleted “${plan.title}”`, {
action: {
label: 'Undo',
onClick: () => {
updateCustomPlans((prev) => (prev.some((p) => p.id === planId) ? prev : [plan, ...prev]));
updateProgress((prev) => [...runs.filter((r) => !prev.some((p) => p.id === r.id)), ...prev]);
},
},
});
}
}, [updateCustomPlans, updateProgress, toast]);
// Patches an existing custom plan. Pass resetProgress: true when the edit // Patches an existing custom plan. Pass resetProgress: true when the edit
// changes its books/days (day indices from before the edit no longer mean // changes its books/days (day indices from before the edit no longer mean
@@ -230,6 +261,11 @@ export function useReadingPlans() {
} }
}, [updateCustomPlans, updateProgress]); }, [updateCustomPlans, updateProgress]);
// Plans from a backup file are added to what is here (nothing is removed).
const importReadingPlans = useCallback((incoming) => {
setState((prev) => ({ ...prev, ...mergeReadingPlans(prev, incoming) }));
}, []);
// Server wins over whatever's local at sign-in. // Server wins over whatever's local at sign-in.
const mergeServerReadingProgress = useCallback((serverData) => { const mergeServerReadingProgress = useCallback((serverData) => {
if (!serverData || typeof serverData !== 'object' || Array.isArray(serverData)) { if (!serverData || typeof serverData !== 'object' || Array.isArray(serverData)) {
@@ -280,6 +316,8 @@ export function useReadingPlans() {
}, [state]); }, [state]);
return { return {
readingPlansState: state,
importReadingPlans,
planProgress: state.progress, planProgress: state.progress,
customPlans, customPlans,
readingActivity: state.activity, readingActivity: state.activity,
+33
View File
@@ -49,3 +49,36 @@ select {
.scrollbar-thin::-webkit-scrollbar-track { .scrollbar-thin::-webkit-scrollbar-track {
background: rgba(148, 163, 184, 0.12); background: rgba(148, 163, 184, 0.12);
} }
/* A clear ring wherever keyboard focus lands (mouse clicks don't show it). */
:where(button, a, summary, [role='button'], [role='tab'], [role='option'], input, select, textarea):focus-visible {
outline: 2px solid var(--r-accent, #a84e27);
outline-offset: 2px;
}
/* "Skip to content" appears only when it's tabbed to. */
.skip-link {
position: absolute;
left: 0.75rem;
top: -4rem;
z-index: 100;
padding: 0.5rem 0.9rem;
border-radius: 9999px;
background: var(--r-text, #2b2620);
color: var(--r-bg, #faf7f0);
font-size: 0.875rem;
}
.skip-link:focus {
top: 0.75rem;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}
+6 -3
View File
@@ -9,6 +9,7 @@ import {
loadProjectIndex, loadProjectIndex,
} from '../utils/bibleUtils.jsx'; } from '../utils/bibleUtils.jsx';
import { loadProjectById } from '../utils/projectStorage.js'; import { loadProjectById } from '../utils/projectStorage.js';
import { compactPassages } from '../utils/passageSummary.js';
import { getPlan, getPlanSchedule, formatDayReadings, nextUnreadDayIndex, computeStreak } from '../utils/readingPlans.js'; import { getPlan, getPlanSchedule, formatDayReadings, nextUnreadDayIndex, computeStreak } from '../utils/readingPlans.js';
export default function HomePage() { export default function HomePage() {
@@ -215,10 +216,12 @@ export default function HomePage() {
<button type="button" onClick={() => { setRenamingId(entry.id); setRenameValue(entry.title ?? ''); }} className="shrink-0 text-xs text-[var(--r-sub)] hover:text-[var(--r-text)]" title="Rename project">✎ Rename</button> <button type="button" onClick={() => { setRenamingId(entry.id); setRenameValue(entry.title ?? ''); }} className="shrink-0 text-xs text-[var(--r-sub)] hover:text-[var(--r-text)]" title="Rename project">✎ Rename</button>
</div> </div>
)} )}
{entry.chapterSummary && <p className="mt-1 text-sm text-[var(--r-sub)]">{entry.chapterSummary}</p>} {entry.chapterSummary && <p className="mt-1 text-sm text-[var(--r-sub)]" title={entry.chapterSummary}>{compactPassages(entry.chapterSummary)}</p>}
{entry.tags?.length > 0 && ( {entry.tags?.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1"> // A few tags and a "+N" for the rest (hover for all; the Tag filter above finds any of them)
{entry.tags.map((tag) => <span key={tag} className="rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[11px] text-[var(--r-sub)]">{tag}</span>)} <div className="mt-2 flex flex-wrap gap-1" title={entry.tags.length > 3 ? entry.tags.join(', ') : undefined}>
{entry.tags.slice(0, 3).map((tag) => <span key={tag} className="rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[11px] text-[var(--r-sub)]">{tag}</span>)}
{entry.tags.length > 3 && <span className="rounded-full px-1.5 py-0.5 text-[11px] text-[var(--r-sub)]">+{entry.tags.length - 3} more</span>}
</div> </div>
)} )}
<p className="mt-1 text-xs text-[var(--r-sub)]">{formatRelativeDate(entry.lastEdited)}</p> <p className="mt-1 text-xs text-[var(--r-sub)]">{formatRelativeDate(entry.lastEdited)}</p>
+205
View File
@@ -0,0 +1,205 @@
// The Outline stage: the shape of the message as a real outline — main points (I.), sub-points (A.),
// and deeper (1., a.) — written after studying the chunks. Each point can carry a passage and link to
// the chunk it comes from. Everything is kept on the project and goes out with the exports.
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { useToast } from '../components/Toasts.jsx';
import StudioShell from '../components/study/StudioShell.jsx';
import { formatChunkReference } from '../utils/bibleUtils.jsx';
import {
addAfter, addChild, addRoot, countNodes, flatten, getNode, indent, insertAt, locate, moveNode, newNode,
outdent, outlineToText, removeNode, seedFromChunks, updateNode,
} from '../utils/outline.js';
const iconBtn = 'flex h-7 w-7 items-center justify-center rounded-md text-sm text-[var(--r-sub)] hover:bg-black/5 hover:text-[var(--r-text)] disabled:opacity-30 disabled:hover:bg-transparent';
const btn = 'rounded-xl border border-[var(--r-line)] px-3.5 py-2 text-sm hover:bg-black/5 disabled:opacity-50';
// A text box that grows with what is typed, so a long point never hides behind a scroll bar.
function PointText({ value, onChange, ...rest }) {
const ref = useRef(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${el.scrollHeight}px`;
}, [value]);
return (
<textarea ref={ref} rows={1} value={value} onChange={(e) => onChange(e.target.value)}
className="block w-full resize-none overflow-hidden rounded-lg border border-transparent bg-transparent px-2 py-1.5 text-[15px] leading-snug text-[var(--r-text)] placeholder:text-[var(--r-sub)] hover:border-[var(--r-line)] focus:border-[var(--r-line)] focus:bg-[var(--r-bg)] focus:outline-none"
{...rest} />
);
}
export default function OutlinePage() {
const { project, allChunks, updateProject, setCurrentPage } = useApp();
const { toast } = useToast();
const tree = project?.outline ?? [];
const rows = flatten(tree);
const [pendingFocus, setPendingFocus] = useState(null);
const [metaOpen, setMetaOpen] = useState(() => new Set()); // points whose passage/chunk fields are showing
// The one way every change is made, always from the latest outline (typing is fast).
const edit = (fn) => updateProject((current) => ({ ...current, outline: fn(current.outline ?? []) }));
useEffect(() => {
if (!pendingFocus) return;
document.querySelector(`[data-outline-id="${pendingFocus}"]`)?.focus();
setPendingFocus(null);
}, [pendingFocus, tree]);
// The chunk choices, named by passage
const chunkChoices = [];
(project?.chapters ?? []).forEach((chapter, chapterIndex) => chapter.chunks.forEach((chunk) => {
chunkChoices.push({ id: chunk.id, label: formatChunkReference(project, chapterIndex, chunk, '–') });
}));
const unlinked = seedFromChunks(project, tree).length - tree.length;
const addMain = () => {
const node = newNode();
edit((t) => addRoot(t, node));
setPendingFocus(node.id);
};
const addSub = (id) => {
const node = newNode();
edit((t) => addChild(t, id, node));
setPendingFocus(node.id);
};
const addSibling = (id) => {
const node = newNode();
edit((t) => addAfter(t, id, node));
setPendingFocus(node.id);
};
const remove = (id) => {
const where = locate(tree, id);
const gone = getNode(tree, id);
if (!where || !gone) return;
const index = rows.findIndex((r) => r.node.id === id);
edit((t) => removeNode(t, id));
setPendingFocus(rows[index - 1]?.node.id ?? null);
const count = countNodes([gone]);
toast(count > 1 ? `Deleted a point and its ${count - 1} sub-point${count > 2 ? 's' : ''}` : 'Deleted a point', {
action: { label: 'Undo', onClick: () => edit((t) => insertAt(t, where.parentId, where.index, gone)) },
});
};
const onKeyDown = (e, id, text, hasChildren) => {
if (e.nativeEvent.isComposing) return;
const at = rows.findIndex((r) => r.node.id === id);
const el = e.currentTarget;
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); addSibling(id); }
else if (e.key === 'Tab') { e.preventDefault(); edit((t) => (e.shiftKey ? outdent(t, id) : indent(t, id))); setPendingFocus(id); }
else if (e.key === 'Backspace' && text === '' && !hasChildren) { e.preventDefault(); remove(id); }
else if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { e.preventDefault(); edit((t) => moveNode(t, id, e.key === 'ArrowUp' ? -1 : 1)); setPendingFocus(id); }
else if (e.key === 'ArrowUp' && el.selectionStart === 0 && rows[at - 1]) { e.preventDefault(); setPendingFocus(rows[at - 1].node.id); }
else if (e.key === 'ArrowDown' && el.selectionStart === text.length && rows[at + 1]) { e.preventDefault(); setPendingFocus(rows[at + 1].node.id); }
};
const copy = async () => {
try { await navigator.clipboard.writeText(outlineToText(tree)); toast('Outline copied'); } catch { toast("Couldn't copy. Select the text and copy it instead.", { tone: 'error' }); }
};
const addChunks = () => {
edit((t) => seedFromChunks(project, t));
toast(`Added ${unlinked} point${unlinked === 1 ? '' : 's'} from your chunks`);
};
const openChunk = (chunkId) => {
updateProject((current) => ({ ...current, selectedChunkId: chunkId }));
setCurrentPage('study');
};
const toggleMeta = (id) => setMetaOpen((s) => { const next = new Set(s); if (next.has(id)) next.delete(id); else next.add(id); return next; });
return (
<StudioShell stage="outline">
<main className="mx-auto max-w-3xl px-4 py-8">
<div className="mb-5 flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-xl font-medium">Outline</h1>
<p className="mt-1 text-sm text-[var(--r-sub)]">The shape of the message: main points, sub-points under them, and deeper if you need it.</p>
</div>
<div className="flex flex-wrap gap-2">
<button type="button" onClick={addMain} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90">+ Main point</button>
<button type="button" onClick={addChunks} disabled={unlinked === 0} className={btn} title="Adds a main point for each chunk that isn't in the outline yet">
Add my chunks{unlinked > 0 ? ` (${unlinked})` : ''}
</button>
<button type="button" onClick={copy} disabled={tree.length === 0} className={btn}>Copy as text</button>
</div>
</div>
{tree.length === 0 ? (
<div className="rounded-2xl border border-dashed border-[var(--r-line)] p-10 text-center">
<p className="text-base font-medium">No outline yet</p>
<p className="mx-auto mt-2 max-w-md text-sm text-[var(--r-sub)]">Start from your chunks to get a main point for each passage you studied, or start blank and write your own.</p>
<div className="mt-5 flex flex-wrap justify-center gap-2">
<button type="button" onClick={addChunks} disabled={unlinked === 0} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50">Start from my chunks</button>
<button type="button" onClick={addMain} className={btn}>Start blank</button>
</div>
</div>
) : (
<ul aria-label="Outline" className="space-y-0.5">
{rows.map(({ node, depth, label }, at) => {
const hasChildren = node.children.length > 0;
const showMeta = metaOpen.has(node.id) || (!!node.ref && false);
const linked = chunkChoices.find((c) => c.id === node.chunkId);
const siblings = rows.filter((r) => r.parentId === rows[at].parentId);
const place = siblings.findIndex((r) => r.node.id === node.id);
return (
<li key={node.id} className="group" style={{ paddingLeft: `${depth * 1.75}rem` }}>
<div className="flex items-start gap-1.5">
<span className="w-9 shrink-0 pt-1.5 text-right font-serif text-sm text-[var(--r-sub)]" aria-hidden="true">{label}</span>
<div className="min-w-0 flex-1">
<PointText
value={node.text} data-outline-id={node.id}
aria-label={`Point ${label}`}
placeholder={depth === 0 ? 'Main point' : 'Sub-point'}
onChange={(value) => edit((t) => updateNode(t, node.id, { text: value }))}
onKeyDown={(e) => onKeyDown(e, node.id, node.text, hasChildren)}
/>
{(node.ref || linked) && !showMeta && (
<p className="px-2 text-xs text-[var(--r-sub)]">
{node.ref}
{linked && <>{node.ref ? ' · ' : ''}<button type="button" onClick={() => openChunk(node.chunkId)} className="text-[var(--r-accent)] hover:underline">Open chunk in Study</button></>}
</p>
)}
{showMeta && (
<div className="mb-1 grid gap-2 px-2 pt-1 sm:grid-cols-2">
<label className="text-xs text-[var(--r-sub)]">Passage
<input value={node.ref} onChange={(e) => edit((t) => updateNode(t, node.id, { ref: e.target.value }))} placeholder="e.g. Titus 1:5–9"
className="mt-0.5 block w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1.5 text-sm text-[var(--r-text)]" />
</label>
<label className="text-xs text-[var(--r-sub)]">Linked chunk
<select value={node.chunkId ?? ''} onChange={(e) => edit((t) => updateNode(t, node.id, {
chunkId: e.target.value || null,
...(e.target.value && !node.ref ? { ref: chunkChoices.find((c) => c.id === e.target.value)?.label ?? '' } : {}),
}))}
className="mt-0.5 block w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1.5 text-sm text-[var(--r-text)]">
<option value="">None</option>
{chunkChoices.map((c) => <option key={c.id} value={c.id}>{c.label}</option>)}
</select>
</label>
</div>
)}
</div>
<div className="flex shrink-0 items-center opacity-0 transition focus-within:opacity-100 group-hover:opacity-100 [@media(hover:none)]:opacity-100">
<button type="button" className={iconBtn} aria-label={`Move ${label} out a level`} title="Out a level (Shift+Tab)" disabled={depth === 0} onClick={() => { edit((t) => outdent(t, node.id)); }}>←</button>
<button type="button" className={iconBtn} aria-label={`Move ${label} in a level`} title="In a level (Tab)" disabled={place === 0} onClick={() => { edit((t) => indent(t, node.id)); }}>→</button>
<button type="button" className={iconBtn} aria-label={`Move ${label} up`} title="Up (Alt+↑)" disabled={place === 0} onClick={() => edit((t) => moveNode(t, node.id, -1))}>↑</button>
<button type="button" className={iconBtn} aria-label={`Move ${label} down`} title="Down (Alt+↓)" disabled={place === siblings.length - 1} onClick={() => edit((t) => moveNode(t, node.id, 1))}>↓</button>
<button type="button" className={iconBtn} aria-label={`Add a sub-point under ${label}`} title="Add a sub-point" onClick={() => addSub(node.id)}>+</button>
<button type="button" className={iconBtn} aria-label={`Passage and chunk for ${label}`} aria-expanded={showMeta} title="Passage and chunk" onClick={() => toggleMeta(node.id)}>⋯</button>
<button type="button" className={`${iconBtn} hover:!text-[var(--r-danger,#b42318)]`} aria-label={`Delete ${label}`} title="Delete" onClick={() => remove(node.id)}>×</button>
</div>
</div>
</li>
);
})}
</ul>
)}
{tree.length > 0 && (
<p className="mt-6 text-xs text-[var(--r-sub)]">
Enter adds a point · Tab and Shift+Tab change its level · Alt+↑ / ↓ moves it · Backspace on an empty point deletes it.
</p>
)}
</main>
</StudioShell>
);
}
+88 -11
View File
@@ -5,7 +5,9 @@ import { useApp } from '../context/AppContext.js';
import { bookOptions } from '../utils/bibleUtils.jsx'; import { bookOptions } from '../utils/bibleUtils.jsx';
import { getPlan, getPlanSchedule, findDayIndexForRef, formatDayReadings, nextUnreadDayIndex, computeStreak, nearestReadingDay } from '../utils/readingPlans.js'; import { getPlan, getPlanSchedule, findDayIndexForRef, formatDayReadings, nextUnreadDayIndex, computeStreak, nearestReadingDay } from '../utils/readingPlans.js';
import DrawCanvas from './DrawCanvas.jsx'; import DrawCanvas from './DrawCanvas.jsx';
import { READER_TRANSLATIONS } from '../hooks/useBibleReader.js'; import { READER_TRANSLATIONS } from '../utils/bibleText.js';
import { useParallelChapter } from '../hooks/useParallelChapter.js';
import { useOfflineSave, useOnline } from '../hooks/useOfflineSave.js';
import { useBibleSearch } from '../hooks/useBibleSearch.js'; import { useBibleSearch } from '../hooks/useBibleSearch.js';
import { themeVars } from '../components/reader/readerThemes.js'; import { themeVars } from '../components/reader/readerThemes.js';
import { TopBar, BottomBar } from '../components/reader/ReaderBars.jsx'; import { TopBar, BottomBar } from '../components/reader/ReaderBars.jsx';
@@ -14,7 +16,9 @@ import PlanStrip from '../components/reader/PlanStrip.jsx';
import NavigatorSheet from '../components/reader/NavigatorSheet.jsx'; import NavigatorSheet from '../components/reader/NavigatorSheet.jsx';
import SettingsSheet from '../components/reader/SettingsSheet.jsx'; import SettingsSheet from '../components/reader/SettingsSheet.jsx';
import SearchSheet from '../components/reader/SearchSheet.jsx'; import SearchSheet from '../components/reader/SearchSheet.jsx';
import BookmarksSheet from '../components/reader/BookmarksSheet.jsx'; import MarksSheet from '../components/reader/MarksSheet.jsx';
import { useToast } from '../components/Toasts.jsx';
import { HIGHLIGHT_COLORS, colorTitle } from '../utils/highlightColors.js';
import AudioSheet from '../components/reader/AudioSheet.jsx'; import AudioSheet from '../components/reader/AudioSheet.jsx';
import VerseSheet from '../components/reader/VerseSheet.jsx'; import VerseSheet from '../components/reader/VerseSheet.jsx';
import PlanSheet from '../components/reader/PlanSheet.jsx'; import PlanSheet from '../components/reader/PlanSheet.jsx';
@@ -22,7 +26,6 @@ import PlanFooter from '../components/reader/PlanFooter.jsx';
import { FocusIcon } from '../components/reader/icons.jsx'; import { FocusIcon } from '../components/reader/icons.jsx';
const NO_STROKES = []; // stable fallback so DrawCanvas doesn't redraw every render const NO_STROKES = []; // stable fallback so DrawCanvas doesn't redraw every render
const HIGHLIGHT_COLORS = ['#fef08a', '#bbf7d0', '#bfdbfe', '#fecaca', '#e9d5ff', '#fed7aa'];
export default function ReaderPage() { export default function ReaderPage() {
const { const {
@@ -40,6 +43,10 @@ export default function ReaderPage() {
readerSelectedVerse, setReaderSelectedVerse, readerSelectedVerse, setReaderSelectedVerse,
readerFontSize, setReaderFontSize, readerFontSize, setReaderFontSize,
readerWide: readerWideLayout, setReaderWide: setReaderWideLayout, readerWide: readerWideLayout, setReaderWide: setReaderWideLayout,
readerFont, setReaderFont,
readerSpacing, setReaderSpacing,
readerVerseNumbers, setReaderVerseNumbers,
readerParallel, setReaderParallel,
readerBookmarks, readerBookmarks,
readerCrossRefs, setReaderCrossRefs, readerCrossRefs, setReaderCrossRefs,
readerCrossRefsLoading, readerCrossRefsLoading,
@@ -56,6 +63,7 @@ export default function ReaderPage() {
readerGoToNextChapter, readerGoToNextChapter,
jumpToReaderVerse, jumpToReaderVerse,
toggleReaderBookmark, toggleReaderBookmark,
restoreReaderBookmark,
cycleBookmarkColor, cycleBookmarkColor,
loadReaderCrossRefs, loadReaderCrossRefs,
loadBibleIndex, loadBibleIndex,
@@ -69,6 +77,8 @@ export default function ReaderPage() {
readerTextHighlights, readerTextHighlights,
addTextHighlight, addTextHighlight,
removeTextHighlight, removeTextHighlight,
restoreTextHighlight,
readerColorLabels, setReaderColorLabel,
planProgress, planProgress,
toggleDay, toggleDay,
toggleChapterRead, toggleChapterRead,
@@ -77,10 +87,18 @@ export default function ReaderPage() {
openPlanReading, openPlanReading,
studyReadingPlanDay, studyReadingPlanDay,
studyBuildBusy, studyBuildBusy,
readerRequest, clearReaderRequest,
} = useApp(); } = useApp();
const { toast } = useToast();
const offline = useOfflineSave();
const online = useOnline();
const readerBook = bookOptions.find((b) => b.abbrev === readerBookAbbrev); const readerBook = bookOptions.find((b) => b.abbrev === readerBookAbbrev);
const readerNotice = READER_TRANSLATIONS.find((t) => t.id === readerTranslation)?.notice ?? ''; const readerNotice = READER_TRANSLATIONS.find((t) => t.id === readerTranslation)?.notice ?? '';
// A second Bible beside the first (never the same one twice, and not while drawing)
const parallelId = readerParallel && readerParallel !== readerTranslation ? readerParallel : '';
const parallelChapter = useParallelChapter(parallelId, readerBookAbbrev, readerChapter);
const parallelSource = READER_TRANSLATIONS.find((t) => t.id === parallelId);
const [sheet, setSheet] = useState(null); // null | 'nav' | 'settings' | 'search' | 'bookmarks' | 'audio' const [sheet, setSheet] = useState(null); // null | 'nav' | 'settings' | 'search' | 'bookmarks' | 'audio'
const [chromeVisible, setChromeVisible] = useState(true); const [chromeVisible, setChromeVisible] = useState(true);
@@ -203,6 +221,17 @@ export default function ReaderPage() {
}, [bibleIndex, bibleIndexLower, deferredBibleQuery]); }, [bibleIndex, bibleIndexLower, deferredBibleQuery]);
const remoteSearch = useBibleSearch(readerTranslation, readerSearch, wholeBibleSearching && !localSearch); const remoteSearch = useBibleSearch(readerTranslation, readerSearch, wholeBibleSearching && !localSearch);
// "Search the Bible for …" from the ⌘K palette: open the whole-Bible search with the text filled in.
useEffect(() => {
if (!readerRequest?.search) return;
setReaderSearchScope('bible');
setReaderSearch(readerRequest.search);
setReaderSearchActive(true);
setSheet('search');
if (localSearch && indexStatus === 'idle') loadBibleIndex(readerTranslation);
clearReaderRequest();
}, [readerRequest]); // eslint-disable-line react-hooks/exhaustive-deps
const openSheet = (name) => { const openSheet = (name) => {
setSheet(name); setSheet(name);
if (name === 'search') setReaderSearchActive(true); if (name === 'search') setReaderSearchActive(true);
@@ -233,9 +262,20 @@ export default function ReaderPage() {
useEffect(() => { useEffect(() => {
const handleKey = (e) => { const handleKey = (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return; if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (e.key === 'ArrowLeft') readerGoToPreviousChapter(); if (e.key === 'ArrowLeft') readerGoToPreviousChapter();
else if (e.key === 'ArrowRight') readerGoToNextChapter(); else if (e.key === 'ArrowRight') readerGoToNextChapter();
else if (e.key === 'Escape') { if (sheet) closeSheet(); else if (readerDrawMode) setReaderDrawMode(false); else setReaderSelectedVerse(null); } else if (e.key === 'Escape') { if (sheet) closeSheet(); else if (readerDrawMode) setReaderDrawMode(false); else setReaderSelectedVerse(null); }
else if (!sheet && !readerDrawMode) {
// One-key shortcuts (the "?" list shows them)
if (e.key === '/') { e.preventDefault(); openSheet('search'); }
else if (e.key === 'g' || e.key === 'G') { e.preventDefault(); openSheet('nav'); }
else if (e.key === ',') { e.preventDefault(); openSheet('settings'); }
else if (e.key === 'f' || e.key === 'F') setChromeVisible((v) => !v);
else if ((e.key === 'b' || e.key === 'B') && selectedKey) {
readerBookmarks[selectedKey] ? removeBookmark(selectedKey, readerBookmarks[selectedKey]) : toggleReaderBookmark(selectedKey);
}
}
}; };
window.addEventListener('keydown', handleKey); window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey); return () => window.removeEventListener('keydown', handleKey);
@@ -249,6 +289,17 @@ export default function ReaderPage() {
return () => document.removeEventListener('pointerdown', dismiss); return () => document.removeEventListener('pointerdown', dismiss);
}, [selectionPicker]); }, [selectionPicker]);
// Removing a highlight or bookmark is one tap, so it can always be taken back.
const removeHighlight = (id) => {
const gone = (readerTextHighlights || []).find((h) => h.id === id);
removeTextHighlight(id);
if (gone) toast('Highlight removed', { action: { label: 'Undo', onClick: () => restoreTextHighlight(gone) } });
};
const removeBookmark = (key, color) => {
toggleReaderBookmark(key);
toast('Bookmark removed', { action: { label: 'Undo', onClick: () => restoreReaderBookmark?.(key, color) } });
};
// Render verse text with colored highlights and optional search match highlight // Render verse text with colored highlights and optional search match highlight
const chapterSearch = sheet === 'search' && readerSearchScope === 'chapter' ? readerSearch.trim().toLowerCase() : ''; const chapterSearch = sheet === 'search' && readerSearchScope === 'chapter' ? readerSearch.trim().toLowerCase() : '';
function renderVerseText(verseNum, text, searchLow) { function renderVerseText(verseNum, text, searchLow) {
@@ -276,7 +327,8 @@ export default function ReaderPage() {
if (s.type === 'hl') { if (s.type === 'hl') {
parts.push( parts.push(
<mark key={s.id} style={{ backgroundColor: s.color, color: '#2b2620', borderRadius: '2px', cursor: 'pointer' }} <mark key={s.id} style={{ backgroundColor: s.color, color: '#2b2620', borderRadius: '2px', cursor: 'pointer' }}
onClick={(e) => { e.stopPropagation(); removeTextHighlight(s.id); }} title="Tap to remove highlight"> onClick={(e) => { e.stopPropagation(); removeHighlight(s.id); }}
title={`${colorTitle(s.color, readerColorLabels ?? {})} · tap to remove`}>
{text.slice(s.start, s.end)} {text.slice(s.start, s.end)}
</mark>, </mark>,
); );
@@ -307,7 +359,7 @@ export default function ReaderPage() {
const selectedText = sel.toString(); const selectedText = sel.toString();
const startOffset = verse.text.indexOf(selectedText); const startOffset = verse.text.indexOf(selectedText);
if (startOffset === -1) return; if (startOffset === -1) return;
setSelectionPicker({ x: (rect.left + rect.right) / 2, y: rect.top, verse: verseNum, startOffset, endOffset: startOffset + selectedText.length }); setSelectionPicker({ x: (rect.left + rect.right) / 2, y: rect.top, verse: verseNum, startOffset, endOffset: startOffset + selectedText.length, text: selectedText });
}, 20); }, 20);
} }
@@ -317,6 +369,12 @@ export default function ReaderPage() {
return { key, color, bookAbbrev: bAbbrev, bookName: bookOptions[bookIndex]?.name ?? bAbbrev, chapter: Number(chapterStr), verse: Number(verseStr), bookIndex }; return { key, color, bookAbbrev: bAbbrev, bookName: bookOptions[bookIndex]?.name ?? bAbbrev, chapter: Number(chapterStr), verse: Number(verseStr), bookIndex };
}).sort((a, b) => a.bookIndex - b.bookIndex || a.chapter - b.chapter || a.verse - b.verse); }).sort((a, b) => a.bookIndex - b.bookIndex || a.chapter - b.chapter || a.verse - b.verse);
// Highlights newest-last in reading order, with the book's name for the list
const highlightEntries = (readerTextHighlights || []).map((h) => {
const bookIndex = bookOptions.findIndex((b) => b.abbrev === h.book);
return { ...h, bookName: bookOptions[bookIndex]?.name ?? h.book, bookIndex };
}).sort((a, b) => a.bookIndex - b.bookIndex || a.chapter - b.chapter || a.verse - b.verse || a.startOffset - b.startOffset);
const selectedVerse = readerSelectedVerse != null ? readerVerses.find((v) => v.number === readerSelectedVerse) : null; const selectedVerse = readerSelectedVerse != null ? readerVerses.find((v) => v.number === readerSelectedVerse) : null;
const selectedKey = selectedVerse ? `${readerBookAbbrev}-${readerChapter}-${selectedVerse.number}` : null; const selectedKey = selectedVerse ? `${readerBookAbbrev}-${readerChapter}-${selectedVerse.number}` : null;
const title = `${readerBook?.name ?? ''} ${readerChapter} · ${readerTranslation}`; const title = `${readerBook?.name ?? ''} ${readerChapter} · ${readerTranslation}`;
@@ -337,7 +395,7 @@ export default function ReaderPage() {
onOpenNavigator={() => openSheet('nav')} onOpenNavigator={() => openSheet('nav')}
onSearch={() => openSheet('search')} onSearch={() => openSheet('search')}
onBookmarks={() => openSheet('bookmarks')} onBookmarks={() => openSheet('bookmarks')}
bookmarkCount={bookmarkEntries.length} bookmarkCount={bookmarkEntries.length + highlightEntries.length}
onSettings={() => openSheet('settings')} onSettings={() => openSheet('settings')}
onDoneDrawing={readerDrawMode ? () => setReaderDrawMode(false) : undefined} onDoneDrawing={readerDrawMode ? () => setReaderDrawMode(false) : undefined}
/> />
@@ -359,11 +417,20 @@ export default function ReaderPage() {
/> />
)} )}
{!online && (
<p role="status" className="border-b border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-1.5 text-center font-sans text-xs text-[var(--r-sub)]">
You’re offline. Chapters you’ve read or saved still open; save a whole book from “Go to” while you’re connected.
</p>
)}
<div className={paneOpen ? 'lg:pr-[24rem]' : ''}> <div className={paneOpen ? 'lg:pr-[24rem]' : ''}>
{!readerDrawMode && ( {!readerDrawMode && (
<ReaderText <ReaderText
title={`${readerBook?.name ?? ''} ${readerChapter}`} title={`${readerBook?.name ?? ''} ${readerChapter}`}
translation={READER_TRANSLATIONS.find((t) => t.id === readerTranslation)?.name ?? readerTranslation} translation={READER_TRANSLATIONS.find((t) => t.id === readerTranslation)?.name ?? readerTranslation}
translationId={readerTranslation}
font={readerFont} spacing={readerSpacing} showVerseNumbers={readerVerseNumbers}
parallel={parallelId ? { translation: parallelId, name: parallelSource?.name ?? parallelId, notice: parallelSource?.notice ?? '', ...parallelChapter } : null}
notice={readerNotice} notice={readerNotice}
verses={readerVerses} verses={readerVerses}
loading={readerLoading} loading={readerLoading}
@@ -445,7 +512,7 @@ export default function ReaderPage() {
crossRefsLoading={readerCrossRefsLoading} crossRefsLoading={readerCrossRefsLoading}
onLoadCrossRefs={() => { if (!readerCrossRefs) loadReaderCrossRefs(readerBookAbbrev, readerChapter); }} onLoadCrossRefs={() => { if (!readerCrossRefs) loadReaderCrossRefs(readerBookAbbrev, readerChapter); }}
bookmarkColor={readerBookmarks[selectedKey]} bookmarkColor={readerBookmarks[selectedKey]}
onToggleBookmark={() => toggleReaderBookmark(selectedKey)} onToggleBookmark={() => (readerBookmarks[selectedKey] ? removeBookmark(selectedKey, readerBookmarks[selectedKey]) : toggleReaderBookmark(selectedKey))}
onCycleColor={() => cycleBookmarkColor(selectedKey)} onCycleColor={() => cycleBookmarkColor(selectedKey)}
onJump={jump} onJump={jump}
onClose={() => setReaderSelectedVerse(null)} onClose={() => setReaderSelectedVerse(null)}
@@ -471,7 +538,7 @@ export default function ReaderPage() {
bookAbbrev={readerBookAbbrev} chapter={readerChapter} bookAbbrev={readerBookAbbrev} chapter={readerChapter}
translation={readerTranslation} onTranslation={setReaderTranslation} translation={readerTranslation} onTranslation={setReaderTranslation}
translationNote={ctxInfo ? `Reading “${ctxInfo.plan.title}”: this sets the plan’s translation.` : ''} translationNote={ctxInfo ? `Reading “${ctxInfo.plan.title}”: this sets the plan’s translation.` : ''}
onGo={goTo} onGo={goTo} offline={offline}
/> />
<SettingsSheet <SettingsSheet
open={sheet === 'settings'} onClose={closeSheet} open={sheet === 'settings'} onClose={closeSheet}
@@ -479,6 +546,10 @@ export default function ReaderPage() {
theme={readerTheme} onTheme={setReaderTheme} theme={readerTheme} onTheme={setReaderTheme}
layout={readerLayout} onLayout={setReaderLayout} layout={readerLayout} onLayout={setReaderLayout}
wide={readerWideLayout} onWide={setReaderWideLayout} wide={readerWideLayout} onWide={setReaderWideLayout}
font={readerFont} onFont={setReaderFont}
spacing={readerSpacing} onSpacing={setReaderSpacing}
verseNumbers={readerVerseNumbers} onVerseNumbers={setReaderVerseNumbers}
translation={readerTranslation} parallel={parallelId} onParallel={setReaderParallel}
/> />
<SearchSheet <SearchSheet
open={sheet === 'search'} onClose={closeSheet} open={sheet === 'search'} onClose={closeSheet}
@@ -490,9 +561,13 @@ export default function ReaderPage() {
bibleMatches={bibleMatches} deferredBibleQuery={deferredBibleQuery} remote={remoteSearch} bibleMatches={bibleMatches} deferredBibleQuery={deferredBibleQuery} remote={remoteSearch}
onJump={jump} onJump={jump}
/> />
<BookmarksSheet <MarksSheet
open={sheet === 'bookmarks'} onClose={closeSheet} open={sheet === 'bookmarks'} onClose={closeSheet}
entries={bookmarkEntries} onJump={jump} onRemove={toggleReaderBookmark} bookmarks={bookmarkEntries} highlights={highlightEntries}
colorLabels={readerColorLabels ?? {}} onSetLabel={setReaderColorLabel}
onJump={jump}
onRemoveBookmark={(e) => removeBookmark(e.key, e.color)}
onRemoveHighlight={(h) => removeHighlight(h.id)}
/> />
<AudioSheet <AudioSheet
open={sheet === 'audio'} onClose={closeSheet} open={sheet === 'audio'} onClose={closeSheet}
@@ -514,7 +589,8 @@ export default function ReaderPage() {
{HIGHLIGHT_COLORS.map((color) => ( {HIGHLIGHT_COLORS.map((color) => (
<button <button
key={color} key={color}
aria-label="Highlight" aria-label={`Highlight ${colorTitle(color, readerColorLabels ?? {}).toLowerCase()}`}
title={colorTitle(color, readerColorLabels ?? {})}
className="h-7 w-7 rounded-full border-2 border-white shadow-sm transition-transform hover:scale-110 active:scale-95" className="h-7 w-7 rounded-full border-2 border-white shadow-sm transition-transform hover:scale-110 active:scale-95"
style={{ backgroundColor: color }} style={{ backgroundColor: color }}
onPointerDown={(e) => { onPointerDown={(e) => {
@@ -522,6 +598,7 @@ export default function ReaderPage() {
addTextHighlight({ addTextHighlight({
book: readerBookAbbrev, chapter: readerChapter, verse: selectionPicker.verse, book: readerBookAbbrev, chapter: readerChapter, verse: selectionPicker.verse,
startOffset: selectionPicker.startOffset, endOffset: selectionPicker.endOffset, color, startOffset: selectionPicker.startOffset, endOffset: selectionPicker.endOffset, color,
text: selectionPicker.text,
}); });
window.getSelection()?.removeAllRanges(); window.getSelection()?.removeAllRanges();
setSelectionPicker(null); setSelectionPicker(null);
+9 -20
View File
@@ -3,8 +3,7 @@
// the custom-plan builder and everything else open as sheets. // the custom-plan builder and everything else open as sheets.
import { useState } from 'react'; import { useState } from 'react';
import { useApp } from '../context/AppContext.js'; import { useApp } from '../context/AppContext.js';
import { themeVars, READER_THEMES } from '../components/reader/readerThemes.js'; import PageShell from '../components/study/PageShell.jsx';
import { ChevronLeft } from '../components/reader/icons.jsx';
import Sheet from '../components/reader/Sheet.jsx'; import Sheet from '../components/reader/Sheet.jsx';
import PlanDetailSheet from '../components/plans/PlanDetailSheet.jsx'; import PlanDetailSheet from '../components/plans/PlanDetailSheet.jsx';
import CustomPlanBuilder from '../components/plans/CustomPlanBuilder.jsx'; import CustomPlanBuilder from '../components/plans/CustomPlanBuilder.jsx';
@@ -14,7 +13,6 @@ import {
} from '../utils/readingPlans.js'; } from '../utils/readingPlans.js';
const CATEGORY_ORDER = ['Whole Bible', 'Old Testament', 'New Testament', 'Topical']; const CATEGORY_ORDER = ['Whole Bible', 'Old Testament', 'New Testament', 'Topical'];
const THEME_ORDER = ['paper', 'sepia', 'dark'];
const pill = 'rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[10px] text-[var(--r-sub)]'; const pill = 'rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[10px] text-[var(--r-sub)]';
const sectionTitle = 'mb-2 mt-7 text-[11px] uppercase tracking-wide text-[var(--r-sub)]'; const sectionTitle = 'mb-2 mt-7 text-[11px] uppercase tracking-wide text-[var(--r-sub)]';
@@ -34,13 +32,12 @@ function nextOf(progress) {
export default function ReadingPlansPage() { export default function ReadingPlansPage() {
const { const {
goHome,
planProgress, customPlans, readingActivity, planProgress, customPlans, readingActivity,
startPlan, toggleDay, restartPlan, removePlanProgress, recalibratePace, setPlanTranslation, startPlan, toggleDay, restartPlan, removePlanProgress, recalibratePace, setPlanTranslation,
addCustomPlan, removeCustomPlan, updateCustomPlan, addCustomPlan, removeCustomPlan, updateCustomPlan,
openPlanReading, studyReadingPlanDay, studyBuildBusy, openPlanReading, studyReadingPlanDay, studyBuildBusy,
readingPlanSyncStatus, readingPlanSyncError, retryReadingPlanSync, readingPlanSyncStatus, readingPlanSyncError, retryReadingPlanSync,
readerTheme, setReaderTheme, readerTranslation, planTranslationPref, readerTranslation, planTranslationPref,
} = useApp(); } = useApp();
const [detail, setDetail] = useState(null); // { planId, progressId? } const [detail, setDetail] = useState(null); // { planId, progressId? }
@@ -59,8 +56,6 @@ export default function ReadingPlansPage() {
const categories = ['All', ...CATEGORY_ORDER.filter((c) => READING_PLANS.some((p) => p.category === c)), ...(customPlans.length ? ['My Plans'] : [])]; const categories = ['All', ...CATEGORY_ORDER.filter((c) => READING_PLANS.some((p) => p.category === c)), ...(customPlans.length ? ['My Plans'] : [])];
const shown = catalog.filter((p) => category === 'All' || p.category === category); const shown = catalog.filter((p) => category === 'All' || p.category === category);
const cycleTheme = () => setReaderTheme(THEME_ORDER[(THEME_ORDER.indexOf(readerTheme) + 1) % THEME_ORDER.length]);
const actions = { const actions = {
defaultTranslation: planTranslationPref && planTranslationPref !== 'same' ? planTranslationPref : readerTranslation, defaultTranslation: planTranslationPref && planTranslationPref !== 'same' ? planTranslationPref : readerTranslation,
setTranslation: setPlanTranslation, setTranslation: setPlanTranslation,
@@ -100,17 +95,11 @@ export default function ReadingPlansPage() {
}; };
return ( return (
<div className="min-h-dvh bg-[var(--r-bg)] pb-16 text-[var(--r-text)]" style={themeVars(readerTheme)}> <PageShell
<div className="safe-top sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)] font-sans"> title="Reading Plans" active="plans" maxWidth="max-w-3xl"
<div className="mx-auto flex h-12 max-w-3xl items-center gap-1 px-2"> actions={streak.current > 0 ? <span className="text-xs text-[var(--r-accent)]">{streak.current} day streak</span> : null}
<button type="button" onClick={goHome} aria-label="Back to home" className="flex h-9 items-center gap-0.5 rounded-lg pl-1 pr-2 text-[var(--r-sub)] hover:bg-black/5"><ChevronLeft /><span className="hidden text-xs sm:inline">Home</span></button> >
<h1 className="mx-auto text-sm font-medium">Reading plans</h1> <main className="mx-auto max-w-3xl px-4 pb-16 pt-5 font-sans">
{streak.current > 0 && <span className="text-xs text-[var(--r-accent)]">{streak.current} day streak</span>}
<button type="button" onClick={cycleTheme} className="ml-2 rounded-lg px-2 py-1.5 text-xs text-[var(--r-sub)] hover:bg-black/5" title="Change theme">{READER_THEMES[readerTheme]?.label}</button>
</div>
</div>
<main className="mx-auto max-w-3xl px-4 pt-5 font-sans">
{readingPlanSyncStatus === 'error' && ( {readingPlanSyncStatus === 'error' && (
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-red-300 px-4 py-3 text-sm"> <div className="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-red-300 px-4 py-3 text-sm">
<span className="text-[var(--r-danger,#b42318)]"> <span className="text-[var(--r-danger,#b42318)]">
@@ -217,7 +206,7 @@ export default function ReadingPlansPage() {
</div> </div>
<div className="flex shrink-0 items-center gap-3 text-xs"> <div className="flex shrink-0 items-center gap-3 text-xs">
<button type="button" onClick={() => restartPlan(p.id)} className="font-medium hover:underline">Start again</button> <button type="button" onClick={() => restartPlan(p.id)} className="font-medium hover:underline">Start again</button>
<button type="button" onClick={() => { if (window.confirm('Remove this from your history?')) removePlanProgress(p.id); }} className="text-[var(--r-danger,#b42318)] hover:underline">Remove</button> <button type="button" onClick={() => removePlanProgress(p.id)} className="text-[var(--r-danger,#b42318)] hover:underline">Remove</button>
</div> </div>
</div> </div>
); );
@@ -240,6 +229,6 @@ export default function ReadingPlansPage() {
<CustomPlanBuilder key={builderMode} initialValues={editingPlan} onSubmit={submitBuilder} onCancel={() => setBuilderMode(null)} /> <CustomPlanBuilder key={builderMode} initialValues={editingPlan} onSubmit={submitBuilder} onCancel={() => setBuilderMode(null)} />
)} )}
</Sheet> </Sheet>
</div> </PageShell>
); );
} }
+5 -1
View File
@@ -3,6 +3,7 @@
// pronunciation guide live here because this is where they're needed. // pronunciation guide live here because this is where they're needed.
import { useState } from 'react'; import { useState } from 'react';
import { useApp } from '../context/AppContext.js'; import { useApp } from '../context/AppContext.js';
import { useToast } from '../components/Toasts.jsx';
import StudioShell from '../components/study/StudioShell.jsx'; import StudioShell from '../components/study/StudioShell.jsx';
import { formatChunkReference } from '../utils/bibleUtils.jsx'; import { formatChunkReference } from '../utils/bibleUtils.jsx';
import { buildClaudePrompt, buildPodcastPrompt } from '../utils/exportBuilders.js'; import { buildClaudePrompt, buildPodcastPrompt } from '../utils/exportBuilders.js';
@@ -15,6 +16,7 @@ const btn = 'rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:
export default function ScriptPage() { export default function ScriptPage() {
const { project, allChunks, updateProject, updateChunk, authUser, setSaveStatus, setCurrentPage } = useApp(); const { project, allChunks, updateProject, updateChunk, authUser, setSaveStatus, setCurrentPage } = useApp();
const [notice, setNotice] = useState(''); const [notice, setNotice] = useState('');
const { toast } = useToast();
const chunk = allChunks.find((c) => c.id === project?.selectedChunkId) ?? allChunks[0] ?? null; const chunk = allChunks.find((c) => c.id === project?.selectedChunkId) ?? allChunks[0] ?? null;
const chapterIndex = chunk ? project.chapters.findIndex((ch) => ch.chunks.some((c) => c.id === chunk.id)) : -1; const chapterIndex = chunk ? project.chapters.findIndex((ch) => ch.chunks.some((c) => c.id === chunk.id)) : -1;
@@ -26,9 +28,11 @@ export default function ScriptPage() {
const set = (patch) => updateChunk(chunk.id, patch); const set = (patch) => updateChunk(chunk.id, patch);
const append = (text) => set({ finalScript: script.trim() ? `${script.replace(/\s+$/, '')}\n\n${text}` : text }); const append = (text) => set({ finalScript: script.trim() ? `${script.replace(/\s+$/, '')}\n\n${text}` : text });
// Replaces the script with an outline from your notes — and says how to get the old one back.
const draft = () => { const draft = () => {
if (has(script) && !window.confirm('Replace the current script with a fresh outline from your notes?')) return; const before = script;
set({ finalScript: buildScriptDraft(chunk, reference) }); set({ finalScript: buildScriptDraft(chunk, reference) });
if (has(before)) toast('Script replaced with a fresh outline', { action: { label: 'Undo', onClick: () => set({ finalScript: before }) } });
}; };
const importDocx = async (file) => { const importDocx = async (file) => {
+13 -2
View File
@@ -1,7 +1,8 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useApp } from '../context/AppContext.js'; import { useApp } from '../context/AppContext.js';
import PageShell from '../components/study/PageShell.jsx'; import PageShell from '../components/study/PageShell.jsx';
import { READER_TRANSLATIONS } from '../hooks/useBibleReader.js'; import { READER_TRANSLATIONS } from '../utils/bibleText.js';
import DataSection from '../components/settings/DataSection.jsx';
import { COMMENTARIES } from '../hooks/useVerseStudy.js'; import { COMMENTARIES } from '../hooks/useVerseStudy.js';
import { READER_THEMES } from '../components/reader/readerThemes.js'; import { READER_THEMES } from '../components/reader/readerThemes.js';
import { import {
@@ -55,7 +56,15 @@ export default function SettingsPage() {
readerWide, setReaderWide, readerWide, setReaderWide,
readerCommentary, setReaderCommentary, readerCommentary, setReaderCommentary,
audioNarrator, setAudioNarrator, audioNarrator, setAudioNarrator,
settingsFocus, clearSettingsFocus,
} = useApp(); } = useApp();
// "Back up my data" from the ⌘K palette lands on that section
useEffect(() => {
if (!settingsFocus) return;
document.getElementById(settingsFocus)?.scrollIntoView({ block: 'start' });
clearSettingsFocus();
}, [settingsFocus]); // eslint-disable-line react-hooks/exhaustive-deps
const [mfaSetup, setMfaSetup] = useState(null); // { secret, qrCodeDataUrl } while setup is in progress const [mfaSetup, setMfaSetup] = useState(null); // { secret, qrCodeDataUrl } while setup is in progress
const [mfaSetupCode, setMfaSetupCode] = useState(''); const [mfaSetupCode, setMfaSetupCode] = useState('');
const [mfaSetupError, setMfaSetupError] = useState(''); const [mfaSetupError, setMfaSetupError] = useState('');
@@ -341,6 +350,8 @@ export default function SettingsPage() {
{podcastNameSaved && <p className="text-sm text-[var(--r-accent)]">Saved.</p>} {podcastNameSaved && <p className="text-sm text-[var(--r-accent)]">Saved.</p>}
</section> </section>
<DataSection />
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-4"> <section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-4">
<div> <div>
<h2 className="text-base font-medium text-[var(--r-text)]">My reading journal</h2> <h2 className="text-base font-medium text-[var(--r-text)]">My reading journal</h2>
+11 -6
View File
@@ -10,18 +10,19 @@
const BASE = '/api'; const BASE = '/api';
async function request(method, path, body) { async function request(method, path, body, { keepalive = false, headers = {} } = {}) {
try { try {
const opts = { const opts = {
method, method,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json', ...headers },
credentials: 'include', credentials: 'include',
}; };
if (keepalive) opts.keepalive = true; // lets the request finish even as the tab closes
if (body !== undefined) opts.body = JSON.stringify(body); if (body !== undefined) opts.body = JSON.stringify(body);
const res = await fetch(`${BASE}${path}`, opts); const res = await fetch(`${BASE}${path}`, opts);
const data = await res.json().catch(() => null); const data = await res.json().catch(() => null);
if (!res.ok) { if (!res.ok) {
return { ok: false, status: res.status, error: data?.error ?? `HTTP ${res.status}` }; return { ok: false, status: res.status, error: data?.error ?? `HTTP ${res.status}`, data };
} }
return { ok: true, data }; return { ok: true, data };
} catch (err) { } catch (err) {
@@ -51,10 +52,14 @@ export async function loadRemoteProject(id) {
/** /**
* Save (create or update) a project on the server. * Save (create or update) a project on the server.
* `baseEdited` is the server copy's lastEdited this device last saw: if the server has moved on, the
* save is refused with { ok: false, status: 409, data: { serverEdited } }. `force` overwrites anyway.
* Returns { ok, data: summary } * Returns { ok, data: summary }
*/ */
export async function saveRemoteProject(project) { export async function saveRemoteProject(project, { baseEdited, force = false } = {}) {
return request('PUT', `/projects/${project.id}`, project); return request('PUT', `/projects/${project.id}${force ? '?force=1' : ''}`, project, {
headers: Number.isFinite(baseEdited) ? { 'X-Base-Edited': String(baseEdited) } : {},
});
} }
/** /**
@@ -62,7 +67,7 @@ export async function saveRemoteProject(project) {
* Returns { ok } * Returns { ok }
*/ */
export async function deleteRemoteProject(id) { export async function deleteRemoteProject(id) {
return request('DELETE', `/projects/${id}`); return request('DELETE', `/projects/${id}`, undefined, { keepalive: true });
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+5
View File
@@ -1,4 +1,9 @@
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import { beforeEach } from 'vitest';
import { clearChapterCache } from './utils/bibleText.js';
// Chapter text is cached for the visit; every test starts with a clean slate.
beforeEach(() => clearChapterCache());
// Node 22+ defines experimental localStorage/sessionStorage globals that are // Node 22+ defines experimental localStorage/sessionStorage globals that are
// undefined unless --localstorage-file is passed. Vitest's jsdom environment // undefined unless --localstorage-file is passed. Vitest's jsdom environment
+43
View File
@@ -97,6 +97,17 @@ describe('htmlToPlainText', () => {
// renderVerseContent // renderVerseContent
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe('renderVerseContent', () => { describe('renderVerseContent', () => {
test('a footnote marker between two words does not run them together', () => {
expect(renderVerseContent(['Let there be light,”', { noteId: 0 }, 'and there was light.'])).toBe('Let there be light,” and there was light.');
expect(renderVerseContent(['an expanse', { noteId: 2 }, 'between the waters'])).toBe('an expanse between the waters');
});
test('a footnote marker is not given a space where punctuation or a space already follows', () => {
expect(renderVerseContent(['the earth', { noteId: 1 }, ', and'])).toBe('the earth, and');
expect(renderVerseContent(['the earth ', { noteId: 1 }, 'and'])).toBe('the earth and');
expect(renderVerseContent(['the end.', { noteId: 3 }])).toBe('the end.');
});
test('returns a string unchanged', () => { test('returns a string unchanged', () => {
expect(renderVerseContent('hello world')).toBe('hello world'); expect(renderVerseContent('hello world')).toBe('hello world');
}); });
@@ -635,3 +646,35 @@ describe('buildClaudePrompt', () => {
expect(prompt).not.toContain('Verse three.'); expect(prompt).not.toContain('Verse three.');
}); });
}); });
// ---------------------------------------------------------------------------
// The outline in exports
// ---------------------------------------------------------------------------
describe('outline in exports', () => {
const outlined = {
...baseProject,
outline: [
{ id: 'a', text: 'Greeting <b>', ref: 'Titus 1:1–4', chunkId: null, children: [{ id: 'a1', text: 'Servant of God', ref: '', chunkId: null, children: [] }] },
{ id: 'b', text: 'Qualifications', ref: '', chunkId: null, children: [] },
],
};
test('HTML shows the outline with labels, nested, escaped, before the chunks', () => {
const html = buildExportHtml(outlined);
expect(html).toContain('class="outline"');
expect(html).toContain('<span class="lbl">I.</span>Greeting &lt;b&gt;');
expect(html).toContain('<span class="lbl">A.</span>Servant of God');
expect(html).toContain('<span class="lbl">II.</span>Qualifications');
expect(html.indexOf('class="outline"')).toBeLessThan(html.indexOf('class="chunk"'));
});
test('Markdown shows the outline under its own heading', () => {
const md = buildMarkdownExport(outlined);
expect(md).toContain('## Outline\n\n- **I.** Greeting <b> (Titus 1:1–4)\n - **A.** Servant of God\n- **II.** Qualifications');
});
test('a study without an outline exports as before', () => {
expect(buildExportHtml(baseProject)).not.toContain('class="outline"');
expect(buildMarkdownExport(baseProject)).not.toContain('## Outline');
});
});
+87
View File
@@ -0,0 +1,87 @@
// "Download my data" / "Restore": everything you've made in one JSON file you keep — studies, bookmarks,
// highlights (and what you named the colours), ink notes and reading plans. Restoring only ever adds or
// updates; it never deletes anything you already have here.
import { loadProjectIndex } from './bibleUtils.jsx';
import { loadProjectById, saveProjectToStorage } from './projectStorage.js';
export const BACKUP_APP = 'bible-study-project';
export const BACKUP_VERSION = 1;
// `reader` is what the app holds in memory: { bookmarks, highlights, colorLabels, ink }.
export function buildBackup({ reader, readingPlans, email = null, now = new Date() }) {
const projects = loadProjectIndex().map((e) => loadProjectById(e.id)).filter(Boolean);
return {
app: BACKUP_APP,
version: BACKUP_VERSION,
exportedAt: now.toISOString(),
account: email,
projects,
reader: {
bookmarks: reader?.bookmarks ?? {},
highlights: reader?.highlights ?? [],
colorLabels: reader?.colorLabels ?? {},
ink: reader?.ink ?? {},
},
readingPlans: readingPlans ?? { progress: [], customPlans: [], activity: [] },
};
}
export const backupFileName = (now = new Date()) => `bible-study-backup-${now.toISOString().slice(0, 10)}.json`;
// -> { ok: true, backup, summary } or { ok: false, error }. Checks the file really is one of ours.
export function validateBackup(data) {
if (!data || typeof data !== 'object' || data.app !== BACKUP_APP) return { ok: false, error: 'That doesn’t look like a Bible Study backup file.' };
if (typeof data.version !== 'number' || data.version > BACKUP_VERSION) return { ok: false, error: 'This backup was made by a newer version of the app.' };
const projects = Array.isArray(data.projects) ? data.projects.filter((p) => p && typeof p.id === 'string' && p.title && Array.isArray(p.chapters)) : [];
const reader = data.reader && typeof data.reader === 'object' ? data.reader : {};
const bookmarks = reader.bookmarks && typeof reader.bookmarks === 'object' && !Array.isArray(reader.bookmarks) ? reader.bookmarks : {};
const highlights = Array.isArray(reader.highlights) ? reader.highlights.filter((h) => h && typeof h.id === 'string') : [];
const colorLabels = reader.colorLabels && typeof reader.colorLabels === 'object' && !Array.isArray(reader.colorLabels) ? reader.colorLabels : {};
const ink = reader.ink && typeof reader.ink === 'object' && !Array.isArray(reader.ink) ? reader.ink : {};
const plans = data.readingPlans && typeof data.readingPlans === 'object' ? data.readingPlans : {};
const backup = {
projects, reader: { bookmarks, highlights, colorLabels, ink },
readingPlans: {
progress: Array.isArray(plans.progress) ? plans.progress : [],
customPlans: Array.isArray(plans.customPlans) ? plans.customPlans : [],
activity: Array.isArray(plans.activity) ? plans.activity : [],
},
};
return {
ok: true, backup,
summary: {
projects: projects.length, bookmarks: Object.keys(bookmarks).length, highlights: highlights.length,
inkPages: Object.keys(ink).length, plans: backup.readingPlans.progress.length + backup.readingPlans.customPlans.length,
},
};
}
// Brings the studies back onto this device: a study that isn't here is added, one that is here is
// replaced only when the backup's copy was edited more recently. -> the projects that were written
// (so the caller can send them to the server too).
export function restoreProjects(projects) {
const written = [];
for (const project of projects) {
const here = loadProjectById(project.id);
if (here && (here.lastEdited ?? 0) >= (project.lastEdited ?? 0)) continue;
written.push(saveProjectToStorage(project));
}
return written;
}
// Reading-plan progress from a backup combined with what is here: each plan run is kept once, and the
// copy with the later activity wins; custom plans and activity days are unioned.
export function mergeReadingPlans(local, incoming) {
const progress = new Map((local.progress ?? []).map((p) => [p.id, p]));
for (const p of incoming.progress ?? []) {
const mine = progress.get(p.id);
if (!mine || (p.lastActivityAt ?? 0) > (mine.lastActivityAt ?? 0)) progress.set(p.id, p);
}
const customPlans = new Map((local.customPlans ?? []).map((p) => [p.id, p]));
for (const p of incoming.customPlans ?? []) if (!customPlans.has(p.id)) customPlans.set(p.id, p);
return {
progress: [...progress.values()],
customPlans: [...customPlans.values()],
activity: [...new Set([...(local.activity ?? []), ...(incoming.activity ?? [])])].sort(),
};
}
+58
View File
@@ -0,0 +1,58 @@
import { beforeEach, expect, test } from 'vitest';
import { backupFileName, buildBackup, mergeReadingPlans, restoreProjects, validateBackup } from './backup.js';
import { loadProjectById, saveProjectToStorage } from './projectStorage.js';
import { switchStorageUser } from './bibleUtils.jsx';
const project = (id, lastEdited, title = 'Romans') => ({
id, title, translation: 'BSB', lastEdited, selectedChunkId: null,
chapters: [{ book: 'Romans', bookAbbrev: 'ROM', chapter: '8', verses: [{ number: 1, text: 'x' }], chunks: [] }],
});
beforeEach(() => { localStorage.clear(); switchStorageUser(null); });
test('a backup holds the studies, marks and plans, and round-trips through validation', () => {
saveProjectToStorage(project('p1', 100));
const backup = buildBackup({
reader: { bookmarks: { 'JHN-3-16': '#fef08a' }, highlights: [{ id: 'h1' }], colorLabels: { '#fef08a': 'Key' }, ink: { JHN_3: [] } },
readingPlans: { progress: [{ id: 'r1' }], customPlans: [], activity: ['2026-09-01'] },
now: new Date('2026-10-01T12:00:00Z'),
});
expect(backup.projects.map((p) => p.id)).toEqual(['p1']);
const checked = validateBackup(JSON.parse(JSON.stringify(backup)));
expect(checked.ok).toBe(true);
expect(checked.summary).toEqual({ projects: 1, bookmarks: 1, highlights: 1, inkPages: 1, plans: 1 });
expect(backupFileName(new Date('2026-10-01T12:00:00Z'))).toBe('bible-study-backup-2026-10-01.json');
});
test('rejects files that are not backups, or are from a newer version', () => {
expect(validateBackup(null).ok).toBe(false);
expect(validateBackup({ app: 'something-else', version: 1 }).ok).toBe(false);
expect(validateBackup({ app: 'bible-study-project', version: 99 }).ok).toBe(false);
});
test('ignores malformed entries instead of failing', () => {
const checked = validateBackup({ app: 'bible-study-project', version: 1, projects: [{ id: 'ok', title: 'T', chapters: [] }, { nope: true }, null], reader: { highlights: [{ id: 'h' }, 5] } });
expect(checked.ok).toBe(true);
expect(checked.summary).toMatchObject({ projects: 1, highlights: 1 });
});
test('restoring adds missing studies and only replaces older copies', () => {
saveProjectToStorage(project('newer', 500, 'Local newer'));
saveProjectToStorage(project('older', 100, 'Local older'));
const written = restoreProjects([project('newer', 200, 'Backup'), project('older', 300, 'Backup newer'), project('missing', 50, 'Brought back')]);
expect(written.map((p) => p.id).sort()).toEqual(['missing', 'older']);
expect(loadProjectById('newer').title).toBe('Local newer');
expect(loadProjectById('older').title).toBe('Backup newer');
expect(loadProjectById('missing').title).toBe('Brought back');
});
test('merging plans keeps every run once, preferring the later activity', () => {
const merged = mergeReadingPlans(
{ progress: [{ id: 'a', lastActivityAt: 10, completedDays: [1] }], customPlans: [{ id: 'c1' }], activity: ['2026-09-02'] },
{ progress: [{ id: 'a', lastActivityAt: 20, completedDays: [1, 2] }, { id: 'b', lastActivityAt: 5 }], customPlans: [{ id: 'c1' }, { id: 'c2' }], activity: ['2026-09-01', '2026-09-02'] },
);
expect(merged.progress.find((p) => p.id === 'a').completedDays).toEqual([1, 2]);
expect(merged.progress).toHaveLength(2);
expect(merged.customPlans.map((p) => p.id)).toEqual(['c1', 'c2']);
expect(merged.activity).toEqual(['2026-09-01', '2026-09-02']);
});
+52
View File
@@ -0,0 +1,52 @@
// The Bibles the Reader can show, and one way to fetch a chapter of any of them. Used by the
// main reading text, the side-by-side view, and the verse "Compare" tab.
import { CHAPTER_COUNTS, parseBibleChapter } from './bibleUtils.jsx';
export const READER_TRANSLATIONS = [
{ id: 'BSB', name: 'Berean Standard Bible' },
{ id: 'ESV', name: 'English Standard Version', licensed: true,
notice: 'Scripture quotations are from the ESV® Bible (The Holy Bible, English Standard Version®), © 2001 by Crossway, a publishing ministry of Good News Publishers. Used by permission. All rights reserved.' },
{ id: 'NET', name: 'NET Bible', helloao: 'eng_net',
notice: 'Scripture quoted by permission. Quotations designated (NET) are from the NET Bible® copyright ©1996-2017 by Biblical Studies Press, L.L.C. http://netbible.com All rights reserved.' },
{ id: 'NLT', name: 'New Living Translation', licensed: true,
notice: 'Holy Bible, New Living Translation, Copyright © 2014, Tyndale House Publishers. All rights reserved. tyndale.com' },
{ id: 'CSB', name: 'Christian Standard Bible', licensed: true,
notice: 'Christian Standard Bible® and CSB® are federally registered trademarks of Holman Bible Publishers. All rights reserved. bhpublishinggroup.com' },
{ id: 'NASB', name: 'New American Standard Bible', licensed: true,
notice: 'New American Standard Bible®, Copyright © 1995, The Lockman Foundation. All rights reserved. lockman.org' },
];
export const translationName = (id) => READER_TRANSLATIONS.find((t) => t.id === id)?.name ?? id;
export const isLicensed = (id) => READER_TRANSLATIONS.find((t) => t.id === id)?.licensed === true;
// Free translations are kept for the visit so flipping between chapters, the side-by-side
// view and Compare don't re-download. Licensed text is never kept here (their terms limit
// storage); the server holds it briefly.
const freeChapters = new Map(); // "BSB/JHN/3" -> { verses, totalChapters }
export const clearChapterCache = () => freeChapters.clear();
// -> { verses: [{ number, text }], copyright, totalChapters }. Throws Error(message) on failure.
// Pass `signal` to cancel a request that is no longer wanted.
export async function fetchChapter(translation, bookAbbrev, chapter, { signal } = {}) {
const source = READER_TRANSLATIONS.find((t) => t.id === translation) ?? READER_TRANSLATIONS[0];
let totalChapters = CHAPTER_COUNTS[bookAbbrev] ?? chapter;
if (!source.licensed) {
const key = `${source.id}/${bookAbbrev}/${chapter}`;
const hit = freeChapters.get(key);
if (hit) return { verses: hit.verses, copyright: '', totalChapters: hit.totalChapters };
const res = await fetch(`https://bible.helloao.org/api/${source.helloao ?? source.id}/${bookAbbrev}/${chapter}.json`, { signal });
if (!res.ok) throw new Error('Unable to load chapter.');
const data = await res.json();
const verses = parseBibleChapter(data);
if (!Array.isArray(verses) || verses.length === 0) throw new Error('Invalid Bible data returned.');
totalChapters = data.book?.numberOfChapters ?? totalChapters;
freeChapters.set(key, { verses, totalChapters });
return { verses, copyright: '', totalChapters };
}
const res = await fetch(`/api/bible/${source.id}/${bookAbbrev}/${chapter}`, { credentials: 'include', signal });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Unable to load ${source.id}.`);
if (!Array.isArray(data.verses) || data.verses.length === 0) throw new Error('Invalid Bible data returned.');
return { verses: data.verses, copyright: data.copyright ?? '', totalChapters };
}
+30 -1
View File
@@ -1,3 +1,5 @@
import { labelFor as outlineLabel } from './outlineLabels.js';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Study constants // Study constants
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -187,6 +189,11 @@ export function buildExportHtml(project) {
.greek th, .greek td { border: 1px solid #d1d5db; padding: 0.65rem; text-align: left; } .greek th, .greek td { border: 1px solid #d1d5db; padding: 0.65rem; text-align: left; }
.greek th { background: #f8fafc; } .greek th { background: #f8fafc; }
.definition { margin-top: 0.75rem; font-size: 0.95rem; line-height: 1.6; } .definition { margin-top: 0.75rem; font-size: 0.95rem; line-height: 1.6; }
.outline ul { list-style: none; margin: 0.25rem 0 0.25rem 1.5rem; padding: 0; }
.outline > ul { margin-left: 0; }
.outline li { margin: 0.3rem 0; line-height: 1.5; }
.outline .lbl { display: inline-block; min-width: 2rem; font-weight: 700; }
.outline .ref { color: #64748b; font-size: 0.9em; }
.definition-block { margin-top: 1rem; padding: 1rem; border: 1px solid #e2e8f0; border-radius: 0.75rem; background: #f8fafc; } .definition-block { margin-top: 1rem; padding: 1rem; border: 1px solid #e2e8f0; border-radius: 0.75rem; background: #f8fafc; }
@media print { @media print {
body { padding: 0; } body { padding: 0; }
@@ -197,6 +204,11 @@ export function buildExportHtml(project) {
const chapters = Array.isArray(project.chapters) ? project.chapters : []; const chapters = Array.isArray(project.chapters) ? project.chapters : [];
// The outline, if one was written: nested lists, labelled I. / A. / 1. like on the page
const outlineHtml = (project.outline ?? []).length > 0
? `<section class="outline"><h2 class="chapter-heading">Outline</h2>${outlineListHtml(project.outline, 0)}</section>`
: '';
const chunksHtml = chapters.map((ch, chapterIndex) => { const chunksHtml = chapters.map((ch, chapterIndex) => {
const chapterHeader = `<h2 class="chapter-heading">${escHtml(ch.book)} ${escHtml(ch.chapter)}</h2>`; const chapterHeader = `<h2 class="chapter-heading">${escHtml(ch.book)} ${escHtml(ch.chapter)}</h2>`;
const chunkSections = ch.chunks.map((chunk) => { const chunkSections = ch.chunks.map((chunk) => {
@@ -284,12 +296,18 @@ export function buildExportHtml(project) {
<div class="page"> <div class="page">
<h1>${escHtml(project.title)}</h1> <h1>${escHtml(project.title)}</h1>
<p>${escHtml(project.translation)}</p> <p>${escHtml(project.translation)}</p>
${outlineHtml}
${chunksHtml} ${chunksHtml}
</div> </div>
</body> </body>
</html>`; </html>`;
} }
// <ul> of outline points, each with its label (I., A., 1. …) and passage
function outlineListHtml(nodes, depth) {
return `<ul>${nodes.map((n, i) => `<li><span class="lbl">${escHtml(outlineLabel(depth, i))}</span>${escHtml((n.text ?? '').trim() || '(untitled)')}${n.ref ? ` <span class="ref">(${escHtml(n.ref)})</span>` : ''}${n.children?.length ? outlineListHtml(n.children, depth + 1) : ''}</li>`).join('')}</ul>`;
}
export function wordTableHtml(rows) { export function wordTableHtml(rows) {
return ` return `
<table> <table>
@@ -341,7 +359,18 @@ export function renderVerseContent(content) {
return content; return content;
} }
if (Array.isArray(content)) { if (Array.isArray(content)) {
return content.map(renderVerseContent).join(''); // A footnote marker ({ noteId }) often sits where the source had a space ("light,” [note] and there
// was light"), which would run the words together. Put the space back when nothing else supplies one.
let out = '';
let afterNote = false;
for (const part of content) {
const piece = renderVerseContent(part);
if (piece === '' && part && typeof part === 'object' && 'noteId' in part) { afterNote = true; continue; }
if (afterNote && out && piece && !/\s$/.test(out) && /^[\p{L}\p{N}“‘"'(]/u.test(piece)) out += ' ';
afterNote = false;
out += piece;
}
return out;
} }
if (content && typeof content === 'object') { if (content && typeof content === 'object') {
if (content.lineBreak) { if (content.lineBreak) {
+17
View File
@@ -14,6 +14,7 @@ import {
WidthType, WidthType,
} from 'docx'; } from 'docx';
import { formatChunkReference, getChunkVerseEntries, htmlToPlainText } from './bibleUtils.jsx'; import { formatChunkReference, getChunkVerseEntries, htmlToPlainText } from './bibleUtils.jsx';
import { flatten } from './outline.js';
export function createParagraphsFromText(text) { export function createParagraphsFromText(text) {
return text return text
@@ -89,6 +90,22 @@ export async function buildStudyDocxBlob(project, chapterSummary) {
const noBorder = { style: BorderStyle.NONE, size: 0, color: 'FFFFFF' }; const noBorder = { style: BorderStyle.NONE, size: 0, color: 'FFFFFF' };
// The outline comes first, indented by level like the page
if ((project.outline ?? []).length > 0) {
children.push(new Paragraph({ text: 'Outline', heading: HeadingLevel.HEADING_1, spacing: { before: 240, after: 160 } }));
flatten(project.outline).forEach(({ node, depth, label }) => {
children.push(new Paragraph({
indent: { left: 420 * depth, hanging: 420 },
spacing: { after: 80 },
children: [
new TextRun({ text: `${label}\t`, bold: true, color: DOCX_ACCENT }),
new TextRun({ text: (node.text ?? '').trim() || '(untitled)' }),
...(node.ref ? [new TextRun({ text: ` (${node.ref})`, italics: true, color: '7A7060' })] : []),
],
}));
});
}
project.chapters.forEach((ch, chapterIndex) => { project.chapters.forEach((ch, chapterIndex) => {
children.push(new Paragraph({ children.push(new Paragraph({
text: `${ch.book} ${ch.chapter}`, text: `${ch.book} ${ch.chapter}`,
+3 -1
View File
@@ -4,6 +4,7 @@ import {
getChunkVerseEntries, getChunkVerseEntries,
htmlToPlainText, htmlToPlainText,
} from './bibleUtils.jsx'; } from './bibleUtils.jsx';
import { outlineToMarkdown } from './outline.js';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Export / prompt builders (exported for testing) // Export / prompt builders (exported for testing)
@@ -48,7 +49,8 @@ export function buildMarkdownExport(project) {
return `## ${ch.book} ${ch.chapter}\n\n${chunkSections}`; return `## ${ch.book} ${ch.chapter}\n\n${chunkSections}`;
}).join('\n'); }).join('\n');
return `# ${project.title}\n\n*${project.translation}*\n\n${chapterSections}`; const outline = (project.outline ?? []).length > 0 ? `## Outline\n\n${outlineToMarkdown(project.outline)}\n\n` : '';
return `# ${project.title}\n\n*${project.translation}*\n\n${outline}${chapterSections}`;
} }
export function buildChunkBodies(project) { export function buildChunkBodies(project) {
+16
View File
@@ -0,0 +1,16 @@
// The colours a highlight can be, and the meanings readers tend to give them. The reader names
// each colour themselves (see the Highlights list); these are only the starting suggestions.
export const HIGHLIGHT_COLORS = ['#fef08a', '#bbf7d0', '#bfdbfe', '#fecaca', '#e9d5ff', '#fed7aa'];
export const COLOR_NAMES = {
'#fef08a': 'Yellow', '#bbf7d0': 'Green', '#bfdbfe': 'Blue',
'#fecaca': 'Red', '#e9d5ff': 'Purple', '#fed7aa': 'Orange',
};
export const COLOR_SUGGESTIONS = {
'#fef08a': 'Key verse', '#bbf7d0': 'Promise', '#bfdbfe': 'Command',
'#fecaca': 'Warning', '#e9d5ff': 'Prayer', '#fed7aa': 'Question',
};
// "Promise (green)" when named, otherwise just "Green".
export const colorTitle = (color, labels = {}) => (labels[color] ? `${labels[color]} (${(COLOR_NAMES[color] ?? 'color').toLowerCase()})` : COLOR_NAMES[color] ?? 'Highlight');
+45
View File
@@ -0,0 +1,45 @@
// "Save for offline": fetch chapters ahead of time so they open without a connection. Fetching is
// all it takes — the service worker (public/sw.js) keeps every Bible-text response it sees. Only the
// free translations (BSB, NET) can be saved; the licensed ones' terms don't allow bulk copies.
import { CHAPTER_COUNTS } from './bibleUtils.jsx';
import { fetchChapter, isLicensed } from './bibleText.js';
const KEY = 'offline-saved';
// { 'BSB': ['JHN', 'PSA'] }: whole books saved on this device, per translation.
export function loadSavedBooks() {
try {
const raw = JSON.parse(localStorage.getItem(KEY) || '{}');
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
} catch { return {}; }
}
export function markBookSaved(translation, book) {
const all = loadSavedBooks();
all[translation] = [...new Set([...(all[translation] ?? []), book])];
try { localStorage.setItem(KEY, JSON.stringify(all)); } catch { /* storage full or unavailable */ }
return all;
}
export const canSaveOffline = (translation) => !isLicensed(translation);
export const bookRefs = (book) => Array.from({ length: CHAPTER_COUNTS[book] ?? 0 }, (_, i) => ({ book, chapter: i + 1 }));
export const bibleRefs = () => Object.keys(CHAPTER_COUNTS).flatMap(bookRefs);
// Fetches every chapter in `refs` ([{ book, chapter }]) a few at a time, calling onProgress(done, total)
// as each lands. Resolves { done, failed } — a chapter that fails doesn't stop the rest. Pass `signal`
// to stop early.
export async function downloadChapters(translation, refs, { onProgress, signal, concurrency = 4 } = {}) {
const queue = [...refs];
let done = 0;
let failed = 0;
const worker = async () => {
while (queue.length > 0 && !signal?.aborted) {
const { book, chapter } = queue.shift();
try { await fetchChapter(translation, book, chapter, { signal }); done += 1; } catch { if (!signal?.aborted) failed += 1; }
onProgress?.(done + failed, refs.length);
}
};
await Promise.all(Array.from({ length: Math.min(concurrency, refs.length) }, worker));
return { done, failed };
}
+44
View File
@@ -0,0 +1,44 @@
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { bibleRefs, bookRefs, canSaveOffline, downloadChapters, loadSavedBooks, markBookSaved } from './offline.js';
beforeEach(() => localStorage.clear());
afterEach(() => vi.unstubAllGlobals());
const chapterResponse = { ok: true, json: () => Promise.resolve({ chapter: { content: [{ type: 'verse', number: 1, content: ['In the beginning.'] }] } }) };
test('lists every chapter of a book and of the whole Bible', () => {
expect(bookRefs('JHN')).toHaveLength(21);
expect(bookRefs('JHN')[0]).toEqual({ book: 'JHN', chapter: 1 });
expect(bibleRefs()).toHaveLength(1189);
});
test('only the free translations can be saved', () => {
expect(canSaveOffline('BSB')).toBe(true);
expect(canSaveOffline('NET')).toBe(true);
expect(canSaveOffline('ESV')).toBe(false);
});
test('remembers which books are saved', () => {
markBookSaved('BSB', 'JHN');
markBookSaved('BSB', 'JHN');
markBookSaved('BSB', 'PSA');
expect(loadSavedBooks()).toEqual({ BSB: ['JHN', 'PSA'] });
});
test('downloads every chapter, reports progress, and keeps going past a failure', async () => {
const fetchMock = vi.fn((url) => Promise.resolve(url.includes('/JHN/2.json') ? { ok: false } : chapterResponse));
vi.stubGlobal('fetch', fetchMock);
const progress = [];
const result = await downloadChapters('BSB', bookRefs('JHN').slice(0, 4), { onProgress: (d, t) => progress.push([d, t]) });
expect(result).toEqual({ done: 3, failed: 1 });
expect(fetchMock).toHaveBeenCalledTimes(4);
expect(progress.at(-1)).toEqual([4, 4]);
});
test('stops when told to', async () => {
const controller = new AbortController();
const fetchMock = vi.fn(() => { controller.abort(); return Promise.resolve(chapterResponse); });
vi.stubGlobal('fetch', fetchMock);
await downloadChapters('BSB', bookRefs('PSA'), { signal: controller.signal, concurrency: 1 });
expect(fetchMock.mock.calls.length).toBeLessThan(5);
});
+164
View File
@@ -0,0 +1,164 @@
// A study's true outline: main points, sub-points under them, and so on (I. / A. / 1. / a.). It is a tree
// of nodes { id, text, ref, chunkId, children } kept on the project as `project.outline`. Every function
// returns a new tree and leaves the one it was given alone.
import { formatChunkReference, makeId } from './bibleUtils.jsx';
import { labelFor, toRoman } from './outlineLabels.js';
export { labelFor, toRoman };
export const MAX_DEPTH = 5; // levels below the main points
export const newNode = (text = '', extra = {}) => ({ id: makeId(), text, ref: '', chunkId: null, children: [], ...extra });
// ---- finding ---------------------------------------------------------------
// Where a node sits: the list of indexes from the top, or null.
export function pathTo(tree, id, base = []) {
for (let i = 0; i < tree.length; i += 1) {
if (tree[i].id === id) return [...base, i];
const deeper = pathTo(tree[i].children, id, [...base, i]);
if (deeper) return deeper;
}
return null;
}
export const getNode = (tree, id) => {
const path = pathTo(tree, id);
if (!path) return null;
return path.reduce((nodes, i, depth) => (depth === path.length - 1 ? nodes[i] : nodes[i].children), tree);
};
// The list of siblings a node is in (a new copy) and its index there, via a path.
function withSiblings(tree, path, change) {
const [head, ...rest] = path;
if (rest.length === 0) return change(tree, head);
return tree.map((n, i) => (i === head ? { ...n, children: withSiblings(n.children, rest, change) } : n));
}
// Depth of the deepest node below `node` (0 when it has no children).
const heightBelow = (node) => (node.children.length === 0 ? 0 : 1 + Math.max(...node.children.map(heightBelow)));
// ---- editing ---------------------------------------------------------------
export const updateNode = (tree, id, patch) => tree.map((n) => (n.id === id ? { ...n, ...patch } : { ...n, children: updateNode(n.children, id, patch) }));
export function addRoot(tree, node) {
return [...tree, node];
}
export function addAfter(tree, id, node) {
const path = pathTo(tree, id);
if (!path) return tree;
return withSiblings(tree, path, (list, i) => [...list.slice(0, i + 1), node, ...list.slice(i + 1)]);
}
export function addChild(tree, id, node) {
if (!getNode(tree, id)) return tree;
return updateChildren(tree, id, (children) => [...children, node]);
}
function updateChildren(tree, id, fn) {
return tree.map((n) => (n.id === id ? { ...n, children: fn(n.children) } : { ...n, children: updateChildren(n.children, id, fn) }));
}
export function removeNode(tree, id) {
const path = pathTo(tree, id);
if (!path) return tree;
return withSiblings(tree, path, (list, i) => list.filter((_, j) => j !== i));
}
// Makes the node a sub-point of the one just above it (if there is one and there is room).
export function indent(tree, id) {
const path = pathTo(tree, id);
if (!path || path[path.length - 1] === 0) return tree;
const node = getNode(tree, id);
if (path.length + heightBelow(node) >= MAX_DEPTH) return tree;
return withSiblings(tree, path, (list, i) => {
const above = list[i - 1];
return [...list.slice(0, i - 1), { ...above, children: [...above.children, node] }, ...list.slice(i + 1)];
});
}
// Brings a sub-point up a level, to sit just after its parent. Its later siblings stay behind, under the parent.
export function outdent(tree, id) {
const path = pathTo(tree, id);
if (!path || path.length < 2) return tree;
const node = getNode(tree, id);
const without = removeNode(tree, id);
return addAfter(without, getNode(tree, parentIdOf(tree, id)).id, node);
}
export function parentIdOf(tree, id, parent = null) {
for (const n of tree) {
if (n.id === id) return parent;
const found = parentIdOf(n.children, id, n.id);
if (found !== null) return found;
}
return null;
}
// Swaps a node with the sibling above (-1) or below (+1).
export function moveNode(tree, id, direction) {
const path = pathTo(tree, id);
if (!path) return tree;
return withSiblings(tree, path, (list, i) => {
const j = i + direction;
if (j < 0 || j >= list.length) return list;
const next = [...list];
[next[i], next[j]] = [next[j], next[i]];
return next;
});
}
// Where a node is: its parent (null at the top) and its place among that parent's children. With
// insertAt this puts a deleted point back exactly where it was.
export function locate(tree, id) {
const path = pathTo(tree, id);
if (!path) return null;
return { parentId: path.length > 1 ? parentIdOf(tree, id) : null, index: path[path.length - 1] };
}
export function insertAt(tree, parentId, index, node) {
const put = (list) => [...list.slice(0, Math.min(index, list.length)), node, ...list.slice(Math.min(index, list.length))];
if (parentId == null || !getNode(tree, parentId)) return put(tree);
return updateChildren(tree, parentId, put);
}
// ---- reading it ------------------------------------------------------------
// Every node in reading order with its depth and label: [{ node, depth, label, parentId }]
export function flatten(tree, depth = 0, parentId = null) {
return tree.flatMap((node, index) => [
{ node, depth, label: labelFor(depth, index), parentId },
...flatten(node.children, depth + 1, node.id),
]);
}
export const countNodes = (tree) => flatten(tree).length;
const pointText = (n) => `${n.text.trim() || '(untitled)'}${n.ref.trim() ? ` (${n.ref.trim()})` : ''}`;
export function outlineToText(tree) {
return flatten(tree).map(({ node, depth, label }) => `${' '.repeat(depth)}${label} ${pointText(node)}`).join('\n');
}
export function outlineToMarkdown(tree) {
return flatten(tree).map(({ node, depth, label }) => `${' '.repeat(depth)}- **${label}** ${pointText(node)}`).join('\n');
}
// ---- starting one ------------------------------------------------------------
// One main point per chunk that isn't already in the outline, named after the session title, or the
// first line of the observation, or the passage. Existing points are kept.
export function seedFromChunks(project, tree = []) {
const linked = new Set(flatten(tree).map((f) => f.node.chunkId).filter(Boolean));
const added = [];
(project?.chapters ?? []).forEach((chapter, chapterIndex) => {
for (const chunk of chapter.chunks ?? []) {
if (linked.has(chunk.id)) continue;
const firstLine = (chunk.observation ?? '').trim().split('\n')[0].slice(0, 90);
added.push(newNode(chunk.episodeTitle?.trim() || firstLine || '', { ref: formatChunkReference(project, chapterIndex, chunk, '–'), chunkId: chunk.id }));
}
});
return [...tree, ...added];
}
+122
View File
@@ -0,0 +1,122 @@
import { describe, expect, test } from 'vitest';
import {
addAfter, addChild, addRoot, countNodes, flatten, getNode, indent, labelFor, moveNode, newNode, outdent,
insertAt, locate, outlineToMarkdown, outlineToText, removeNode, seedFromChunks, toRoman, updateNode, MAX_DEPTH,
} from './outline.js';
const n = (id, text = id, children = []) => ({ id, text, ref: '', chunkId: null, children });
const ids = (tree) => flatten(tree).map((f) => f.node.id);
// I. a (with A. a1, B. a2) II. b
const sample = () => [n('a', 'a', [n('a1'), n('a2')]), n('b')];
describe('labels', () => {
test('roman numerals, letters and numbers by level', () => {
expect([1, 4, 9, 14, 19].map(toRoman)).toEqual(['I', 'IV', 'IX', 'XIV', 'XIX']);
expect(labelFor(0, 1)).toBe('II.');
expect(labelFor(1, 0)).toBe('A.');
expect(labelFor(1, 26)).toBe('AA.');
expect(labelFor(2, 2)).toBe('3.');
expect(labelFor(3, 1)).toBe('b.');
expect(labelFor(4, 0)).toBe('(1)');
});
test('flatten gives reading order with depth and labels', () => {
expect(flatten(sample()).map((f) => `${f.label}${f.node.id}@${f.depth}`)).toEqual(['I.a@0', 'A.a1@1', 'B.a2@1', 'II.b@0']);
});
});
describe('editing', () => {
test('add after, add child, add root', () => {
expect(ids(addAfter(sample(), 'a1', n('x')))).toEqual(['a', 'a1', 'x', 'a2', 'b']);
expect(ids(addChild(sample(), 'b', n('x')))).toEqual(['a', 'a1', 'a2', 'b', 'x']);
expect(ids(addRoot(sample(), n('x')))).toEqual(['a', 'a1', 'a2', 'b', 'x']);
expect(flatten(addChild(sample(), 'b', n('x'))).at(-1).depth).toBe(1);
});
test('update and remove, including a node deep in the tree', () => {
expect(getNode(updateNode(sample(), 'a2', { text: 'new' }), 'a2').text).toBe('new');
expect(ids(removeNode(sample(), 'a1'))).toEqual(['a', 'a2', 'b']);
expect(ids(removeNode(sample(), 'a'))).toEqual(['b']); // takes its sub-points with it
});
test('indent makes it a sub-point of the one above; the first one cannot', () => {
const t = indent(sample(), 'b');
expect(flatten(t).map((f) => `${f.node.id}@${f.depth}`)).toEqual(['a@0', 'a1@1', 'a2@1', 'b@1']);
expect(indent(sample(), 'a')).toEqual(sample());
expect(indent(sample(), 'a1')).toEqual(sample());
});
test('indent carries a point\'s own sub-points with it', () => {
const t = indent([n('p'), n('q', 'q', [n('q1')])], 'q');
expect(flatten(t).map((f) => `${f.node.id}@${f.depth}`)).toEqual(['p@0', 'q@1', 'q1@2']);
});
test('indent stops at the deepest level', () => {
// a chain: top > s1 > s2 > ... pushing every new point in as far as it will go
let tree = [n('top')];
for (let i = 1; i <= MAX_DEPTH + 2; i += 1) {
tree = addRoot(tree, n(`s${i}`));
for (let k = 0; k < MAX_DEPTH + 2; k += 1) tree = indent(tree, `s${i}`);
}
const deepest = Math.max(...flatten(tree).map((f) => f.depth));
expect(deepest).toBe(MAX_DEPTH - 1);
});
test('outdent moves a sub-point to sit just after its parent', () => {
const t = outdent(sample(), 'a1');
expect(flatten(t).map((f) => `${f.node.id}@${f.depth}`)).toEqual(['a@0', 'a2@1', 'a1@0', 'b@0']);
expect(outdent(sample(), 'a')).toEqual(sample()); // already at the top
});
test('move swaps with a sibling and stops at the ends', () => {
expect(ids(moveNode(sample(), 'b', -1))).toEqual(['b', 'a', 'a1', 'a2']);
expect(ids(moveNode(sample(), 'a2', -1))).toEqual(['a', 'a2', 'a1', 'b']);
expect(moveNode(sample(), 'a', -1)).toEqual(sample());
expect(moveNode(sample(), 'b', 1)).toEqual(sample());
});
test('edits never change the tree they are given', () => {
const t = sample();
const copy = JSON.stringify(t);
indent(t, 'b'); outdent(t, 'a1'); removeNode(t, 'a'); moveNode(t, 'b', -1); updateNode(t, 'a', { text: 'z' });
expect(JSON.stringify(t)).toBe(copy);
});
});
describe('reading it back', () => {
test('plain text and markdown carry labels, indentation and passages', () => {
const t = [{ ...n('a', 'Creation'), ref: 'Gen 1:1-2', children: [n('a1', 'Light')] }, n('b', '')];
expect(outlineToText(t)).toBe('I. Creation (Gen 1:1-2)\n A. Light\nII. (untitled)');
expect(outlineToMarkdown(t)).toBe('- **I.** Creation (Gen 1:1-2)\n - **A.** Light\n- **II.** (untitled)');
expect(countNodes(t)).toBe(3);
});
});
describe('seeding from chunks', () => {
const project = {
chapters: [{ book: 'Titus', bookAbbrev: 'TIT', chapter: '1', verses: [{ number: 1, text: 'a' }, { number: 2, text: 'b' }, { number: 3, text: 'c' }], chunks: [
{ id: 'c1', startVerse: 1, endVerse: 2, observation: 'Paul greets.\nMore.', episodeTitle: '' },
{ id: 'c2', startVerse: 3, endVerse: 3, observation: '', episodeTitle: 'Hope' },
] }],
};
test('one main point per chunk, linked to it and named from its notes or title', () => {
const t = seedFromChunks(project);
expect(t.map((x) => [x.text, x.ref, x.chunkId])).toEqual([['Paul greets.', 'Titus 1:1–2', 'c1'], ['Hope', 'Titus 1:3', 'c2']]);
});
test('keeps what is there and only adds chunks that are not yet in the outline', () => {
const first = seedFromChunks(project);
const edited = updateNode(first, first[0].id, { text: 'My own wording' });
const again = seedFromChunks(project, edited);
expect(again).toHaveLength(2);
expect(again[0].text).toBe('My own wording');
expect(seedFromChunks(project, [newNode('Extra')]).map((x) => x.chunkId)).toEqual([null, 'c1', 'c2']);
});
});
describe('putting a deleted point back', () => {
test('locate + insertAt restores a point, with its sub-points, exactly where it was', () => {
const tree = sample();
const where = locate(tree, 'a1');
const gone = getNode(tree, 'a1');
expect(where).toEqual({ parentId: 'a', index: 0 });
expect(ids(insertAt(removeNode(tree, 'a1'), where.parentId, where.index, gone))).toEqual(ids(tree));
const top = locate(tree, 'a');
expect(ids(insertAt(removeNode(tree, 'a'), top.parentId, top.index, getNode(tree, 'a')))).toEqual(ids(tree));
});
test('if the parent is gone it goes to the top level instead of vanishing', () => {
expect(ids(insertAt([n('b')], 'missing', 0, n('x')))).toEqual(['x', 'b']);
});
});
+28
View File
@@ -0,0 +1,28 @@
// How outline points are numbered. Kept free of imports so both the page and the exporters can use it.
const ROMAN = [[10, 'X'], [9, 'IX'], [5, 'V'], [4, 'IV'], [1, 'I']];
export function toRoman(n) {
let out = '';
let rest = n;
for (const [value, symbol] of ROMAN) while (rest >= value) { out += symbol; rest -= value; }
return out;
}
const letter = (n, upper) => {
let out = '';
let rest = n;
while (rest > 0) { rest -= 1; out = String.fromCharCode((upper ? 65 : 97) + (rest % 26)) + out; rest = Math.floor(rest / 26); }
return out;
};
// I. / A. / 1. / a. / (1) / (a), by level
export function labelFor(depth, index) {
const n = index + 1;
switch (depth) {
case 0: return `${toRoman(n)}.`;
case 1: return `${letter(n, true)}.`;
case 2: return `${n}.`;
case 3: return `${letter(n, false)}.`;
case 4: return `(${n})`;
default: return `(${letter(n, false)})`;
}
}
+51
View File
@@ -0,0 +1,51 @@
// Turns what someone types — "john 3:16", "1 cor 13", "Ps 23", "jn3" — into a place in the Bible.
import { bookOptions, CHAPTER_COUNTS } from './bibleUtils.jsx';
// Short forms people actually type that aren't just the start of the book's name.
const ALIASES = {
gn: 'GEN', ex: 'EXO', exod: 'EXO', lv: 'LEV', nm: 'NUM', nu: 'NUM', dt: 'DEU', deut: 'DEU', jos: 'JOS', josh: 'JOS',
jdg: 'JDG', judg: 'JDG', jg: 'JDG', rt: 'RUT', '1sm': '1SA', '2sm': '2SA', '1ki': '1KI', '2ki': '2KI', '1kgs': '1KI', '2kgs': '2KI',
'1chr': '1CH', '2chr': '2CH', ne: 'NEH', est: 'EST', jb: 'JOB', ps: 'PSA', psa: 'PSA', pslm: 'PSA', psalm: 'PSA', psalms: 'PSA',
pr: 'PRO', prov: 'PRO', prv: 'PRO', ecc: 'ECC', eccl: 'ECC', qoh: 'ECC', song: 'SNG', sos: 'SNG', ss: 'SNG', songofsongs: 'SNG', songofsolomon: 'SNG', canticles: 'SNG',
is: 'ISA', isa: 'ISA', jer: 'JER', lam: 'LAM', ez: 'EZK', ezek: 'EZK', dn: 'DAN', hos: 'HOS', jl: 'JOL', joel: 'JOL',
am: 'AMO', ob: 'OBA', obad: 'OBA', jon: 'JON', mi: 'MIC', mic: 'MIC', na: 'NAM', nah: 'NAM', hab: 'HAB', zep: 'ZEP', zeph: 'ZEP',
hag: 'HAG', zec: 'ZEC', zech: 'ZEC', mal: 'MAL',
mt: 'MAT', matt: 'MAT', mk: 'MRK', mr: 'MRK', mrk: 'MRK', lk: 'LUK', lu: 'LUK', jn: 'JHN', jhn: 'JHN', joh: 'JHN',
ac: 'ACT', rom: 'ROM', rm: 'ROM', '1cor': '1CO', '2cor': '2CO', '1co': '1CO', '2co': '2CO', gal: 'GAL', eph: 'EPH',
phil: 'PHP', php: 'PHP', pp: 'PHP', col: 'COL', '1thess': '1TH', '2thess': '2TH', '1th': '1TH', '2th': '2TH',
'1tim': '1TI', '2tim': '2TI', '1ti': '1TI', '2ti': '2TI', tit: 'TIT', phm: 'PHM', philem: 'PHM', heb: 'HEB',
jas: 'JAS', jm: 'JAS', '1pet': '1PE', '2pet': '2PE', '1pe': '1PE', '2pe': '2PE', '1jn': '1JN', '2jn': '2JN', '3jn': '3JN',
'1john': '1JN', '2john': '2JN', '3john': '3JN', jud: 'JUD', rev: 'REV', rv: 'REV', revelations: 'REV',
};
const squash = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
const BOOKS = bookOptions.map((b) => ({ ...b, key: squash(b.name) }));
// The books a typed name could mean, best first: exact, then a known short form, then any
// book whose name starts with it ("deut" → Deuteronomy, "1 cor" → 1 Corinthians).
export function matchBooks(typed) {
const key = squash(typed);
if (!key) return [];
const exact = BOOKS.filter((b) => b.key === key);
if (exact.length) return exact;
const alias = ALIASES[key] && BOOKS.filter((b) => b.abbrev === ALIASES[key]);
if (alias?.length) return alias;
return BOOKS.filter((b) => b.key.startsWith(key));
}
// "John 3:16" -> [{ book: 'JHN', bookName: 'John', chapter: 3, verse: 16 }]; ambiguous names ("jo")
// give several. A bare book ("romans") opens chapter 1. Chapters past the end are rejected.
export function parsePassageRef(text) {
const m = String(text ?? '').trim().replace(/\./g, ' ').match(/^((?:[1-3]\s*)?[a-z][a-z ]*?)\s*(?:(\d{1,3})(?:\s*[:.v ]\s*(\d{1,3}))?)?$/i);
if (!m) return [];
const [, name, chapterText, verseText] = m;
const out = [];
for (const b of matchBooks(name).slice(0, 4)) {
const chapter = chapterText ? Number(chapterText) : 1;
if (chapter < 1 || chapter > (CHAPTER_COUNTS[b.abbrev] ?? 1)) continue;
out.push({ book: b.abbrev, bookName: b.name, chapter, verse: verseText ? Number(verseText) : null, hasChapter: !!chapterText });
}
return out;
}
export const formatPassage = (r) => `${r.bookName}${r.hasChapter || r.chapter > 1 ? ` ${r.chapter}` : ''}${r.verse ? `:${r.verse}` : ''}`;
+37
View File
@@ -0,0 +1,37 @@
import { expect, test } from 'vitest';
import { formatPassage, matchBooks, parsePassageRef } from './passageRef.js';
const first = (text) => parsePassageRef(text)[0];
test('understands a book, chapter and verse in the usual ways', () => {
expect(first('John 3:16')).toMatchObject({ book: 'JHN', chapter: 3, verse: 16 });
expect(first('jn 3 16')).toMatchObject({ book: 'JHN', chapter: 3, verse: 16 });
expect(first('Ps 23')).toMatchObject({ book: 'PSA', chapter: 23, verse: null });
expect(first('1 cor 13:4')).toMatchObject({ book: '1CO', chapter: 13, verse: 4 });
expect(first('1corinthians 13')).toMatchObject({ book: '1CO', chapter: 13 });
expect(first('song of solomon 2')).toMatchObject({ book: 'SNG', chapter: 2 });
expect(first('Rom. 8.28')).toMatchObject({ book: 'ROM', chapter: 8, verse: 28 });
});
test('a bare book name opens its first chapter', () => {
expect(first('romans')).toMatchObject({ book: 'ROM', chapter: 1, hasChapter: false });
});
test('a name that fits several books lists them; a short form picks one', () => {
expect(matchBooks('jo').length).toBeGreaterThan(1);
expect(matchBooks('jn').map((b) => b.abbrev)).toEqual(['JHN']);
});
test('rejects chapters a book does not have, and things that are not passages', () => {
expect(parsePassageRef('jude 2')).toEqual([]); // Jude has one chapter
expect(parsePassageRef('genesis 51')).toEqual([]);
expect(parsePassageRef('')).toEqual([]);
expect(parsePassageRef('3:16')).toEqual([]);
expect(parsePassageRef('zzz 4')).toEqual([]);
});
test('formats a place the way people write it', () => {
expect(formatPassage(first('john 3:16'))).toBe('John 3:16');
expect(formatPassage(first('psalm 23'))).toBe('Psalms 23');
expect(formatPassage(first('romans'))).toBe('Romans');
});
+23
View File
@@ -0,0 +1,23 @@
// "Luke 1, Luke 2, Luke 3, Luke 5" -> "Luke 1–3, 5": a long run of chapters reads as ranges, so a
// study card stays short. Anything that isn't "Book N" is left as it was.
export function compactPassages(summary) {
if (!summary) return '';
const parts = summary.split(/,\s*/);
const groups = [];
for (const part of parts) {
const m = part.match(/^(.+?)\s+(\d+)$/);
if (!m) { groups.push({ raw: part }); continue; }
const last = groups[groups.length - 1];
if (last && last.book === m[1]) last.chapters.push(Number(m[2]));
else groups.push({ book: m[1], chapters: [Number(m[2])] });
}
return groups.map((g) => {
if (g.raw !== undefined) return g.raw;
const runs = [];
for (const n of g.chapters) {
const run = runs[runs.length - 1];
if (run && n === run[1] + 1) run[1] = n; else runs.push([n, n]);
}
return `${g.book} ${runs.map(([a, b]) => (a === b ? a : b === a + 1 ? `${a}, ${b}` : `${a}–${b}`)).join(', ')}`;
}).join(', ');
}
+17
View File
@@ -0,0 +1,17 @@
import { expect, test } from 'vitest';
import { compactPassages } from './passageSummary.js';
test('runs of chapters become ranges', () => {
expect(compactPassages('Luke 1, Luke 2, Luke 3, Luke 4, Luke 5, Luke 6')).toBe('Luke 1–6');
expect(compactPassages('1 John 1, 1 John 2, 1 John 3, 1 John 4, 1 John 5')).toBe('1 John 1–5');
});
test('gaps stay separate, pairs and singles stay plain', () => {
expect(compactPassages('Luke 1, Luke 2, Luke 3, Luke 5')).toBe('Luke 1–3, 5');
expect(compactPassages('Malachi 1, Malachi 2')).toBe('Malachi 1, 2');
expect(compactPassages('John 3')).toBe('John 3');
});
test('several books, and text that is not a chapter, are kept', () => {
expect(compactPassages('Ephesians 1, Ephesians 2, Ephesians 3, Titus 1')).toBe('Ephesians 1–3, Titus 1');
expect(compactPassages('Whole book')).toBe('Whole book');
expect(compactPassages('')).toBe('');
});
+70
View File
@@ -0,0 +1,70 @@
// Search across everything the reader has written or saved on this device: study notes, project
// titles and passages, bookmarks and highlights. (Bible text itself is searched in the Reader.)
import { bookOptions, formatChunkReference } from './bibleUtils.jsx';
const NOTE_FIELDS = [
['observation', 'Observation'], ['interpretation', 'Interpretation'], ['application', 'Application'],
['generalNotes', 'Notes'], ['finalScript', 'Script'],
];
const snippetAround = (raw, at, len) => {
const start = Math.max(0, at - 40);
const end = at + len + 60;
return (start > 0 ? '…' : '') + raw.slice(start, end).replace(/\s+/g, ' ') + (end < raw.length ? '…' : '');
};
// projects: full project objects. -> [{ projectId, projectTitle, chunkId, ref, field, snippet }], at most
// `limit`, one hit per chunk (the first field that matches).
export function searchNotes(projects, query, { limit = 20 } = {}) {
const q = query.trim().toLowerCase();
if (q.length < 3) return [];
const hits = [];
for (const project of projects) {
for (const [chapterIndex, chapter] of (project.chapters ?? []).entries()) {
for (const chunk of chapter.chunks ?? []) {
for (const [field, label] of NOTE_FIELDS) {
const raw = chunk[field] ?? '';
const at = raw.toLowerCase().indexOf(q);
if (at === -1) continue;
hits.push({
projectId: project.id, projectTitle: project.title, chunkId: chunk.id,
ref: formatChunkReference(project, chapterIndex, chunk, '–'), field: label, snippet: snippetAround(raw, at, q.length),
});
break;
}
if (hits.length >= limit) return hits;
}
}
}
return hits;
}
// Projects whose title, passages or tags contain the text.
export function searchProjects(index, query, { limit = 6 } = {}) {
const q = query.trim().toLowerCase();
if (!q) return [];
return index.filter((e) => [e.title, e.chapterSummary, ...(e.tags ?? [])].some((t) => (t ?? '').toLowerCase().includes(q))).slice(0, limit);
}
const bookName = (abbrev) => bookOptions.find((b) => b.abbrev === abbrev)?.name ?? abbrev;
// Highlights (by their words or what the colour is named) and bookmarks (by reference).
// -> [{ kind: 'highlight' | 'bookmark', book, bookName, chapter, verse, color, text?, label? }]
export function searchMarks({ bookmarks = {}, highlights = [], labels = {} }, query, { limit = 10 } = {}) {
const q = query.trim().toLowerCase();
if (q.length < 2) return [];
const out = [];
for (const h of highlights) {
const label = labels[h.color] ?? '';
const ref = `${bookName(h.book)} ${h.chapter}:${h.verse}`.toLowerCase();
if ((h.text ?? '').toLowerCase().includes(q) || label.toLowerCase().includes(q) || ref.includes(q)) {
out.push({ kind: 'highlight', book: h.book, bookName: bookName(h.book), chapter: h.chapter, verse: h.verse, color: h.color, text: h.text, label });
}
}
for (const [key, color] of Object.entries(bookmarks)) {
const [book, chapter, verse] = key.split('-');
const ref = `${bookName(book)} ${chapter}:${verse}`;
if (ref.toLowerCase().includes(q)) out.push({ kind: 'bookmark', book, bookName: bookName(book), chapter: Number(chapter), verse: Number(verse), color });
}
return out.slice(0, limit);
}
+42
View File
@@ -0,0 +1,42 @@
import { expect, test } from 'vitest';
import { searchMarks, searchNotes, searchProjects } from './personalSearch.js';
const project = {
id: 'p1', title: 'Romans study',
chapters: [{
book: 'Romans', bookAbbrev: 'ROM', chapter: '8', verses: [{ number: 1, text: 'a' }, { number: 2, text: 'b' }],
chunks: [
{ id: 'c1', startVerse: 1, endVerse: 2, observation: 'No condemnation for those in Christ.', interpretation: '', application: '', generalNotes: '', finalScript: '' },
{ id: 'c2', startVerse: 2, endVerse: 2, observation: '', interpretation: 'The Spirit of life sets us free', application: '', generalNotes: '', finalScript: '' },
],
}],
};
test('finds notes, one hit per chunk, saying which field matched', () => {
const hits = searchNotes([project], 'spirit');
expect(hits).toHaveLength(1);
expect(hits[0]).toMatchObject({ projectId: 'p1', chunkId: 'c2', field: 'Interpretation' });
expect(hits[0].snippet).toContain('Spirit of life');
});
test('needs three letters before searching notes', () => {
expect(searchNotes([project], 'sp')).toEqual([]);
});
test('finds projects by title, passage or tag', () => {
const index = [{ id: 'a', title: 'Romans study', chapterSummary: 'Romans 8', tags: ['grace'] }, { id: 'b', title: 'Psalms', chapterSummary: 'Psalms 23', tags: [] }];
expect(searchProjects(index, 'rom').map((e) => e.id)).toEqual(['a']);
expect(searchProjects(index, 'grace').map((e) => e.id)).toEqual(['a']);
expect(searchProjects(index, 'zzz')).toEqual([]);
});
test('finds highlights by their words or colour name, and bookmarks by reference', () => {
const marks = {
bookmarks: { 'JHN-3-16': '#fef08a' },
highlights: [{ id: 'h1', book: 'ROM', chapter: 8, verse: 28, color: '#bbf7d0', text: 'all things work together', startOffset: 0, endOffset: 5 }],
labels: { '#bbf7d0': 'Promise' },
};
expect(searchMarks(marks, 'together')[0]).toMatchObject({ kind: 'highlight', bookName: 'Romans', verse: 28 });
expect(searchMarks(marks, 'promise')[0]).toMatchObject({ kind: 'highlight' });
expect(searchMarks(marks, 'john 3')[0]).toMatchObject({ kind: 'bookmark', chapter: 3, verse: 16 });
});
+9
View File
@@ -0,0 +1,9 @@
// Remembers, per study, the server copy's lastEdited that this device last saw — the "base" a save is
// checked against so another device's newer copy is never overwritten silently. Kept in memory: it is
// rebuilt from the server's list each time you sign in.
const base = new Map();
export const getSyncBase = (id) => base.get(id);
export const setSyncBase = (id, lastEdited) => { if (Number.isFinite(lastEdited)) base.set(id, lastEdited); };
export const clearSyncBase = (id) => base.delete(id);
export const resetSyncBases = () => base.clear();