Merge ui-features-flow: Outline stage, parallel reading, offline, backup, sync conflicts
Build and Push Docker Image / build-and-push (push) Failing after 21s
Build and Push Docker Image / build-and-push (push) Failing after 21s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
d273c8cb3e
65 files changed
+2975
-208
No files matched your search
+18
-6
@@ -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;
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
@@ -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,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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
// How the app moves and recovers: the Back button, the ⌘K palette, Undo, sync conflicts, backups.
|
||||||
|
import { render, screen, waitFor, within, fireEvent } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { vi, beforeEach, afterEach, describe, test, expect } from 'vitest';
|
||||||
|
import App from './App';
|
||||||
|
import { resetLexiconCaches } from './utils/wordStudy.js';
|
||||||
|
import { switchStorageUser } from './utils/bibleUtils.jsx';
|
||||||
|
import { resetSyncBases } from './utils/syncBase.js';
|
||||||
|
|
||||||
|
const chapterData = {
|
||||||
|
chapter: { content: [
|
||||||
|
{ type: 'verse', number: 1, content: ['In the beginning God created.'] },
|
||||||
|
{ type: 'verse', number: 2, content: ['The earth was without form.'] },
|
||||||
|
] },
|
||||||
|
};
|
||||||
|
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
|
||||||
|
|
||||||
|
// A fake server: signed in as `user`, a settable answer for saving studies, and a log of every call.
|
||||||
|
function stubServer({ user = null, putStatus = 200 } = {}) {
|
||||||
|
const calls = [];
|
||||||
|
const state = { putStatus };
|
||||||
|
vi.stubGlobal('fetch', vi.fn((url, opts = {}) => {
|
||||||
|
const route = `${opts.method ?? 'GET'} ${url.split('?')[0]}`;
|
||||||
|
calls.push({ route, url, opts });
|
||||||
|
if (url.includes('bible.helloao.org')) return json(200, chapterData);
|
||||||
|
if (!url.startsWith('/api/')) return Promise.resolve({ ok: false });
|
||||||
|
if (route === 'GET /api/auth/me') return user ? json(200, user) : json(401, { error: 'Not signed in' });
|
||||||
|
if (route === 'GET /api/projects') return json(200, []);
|
||||||
|
if (route === 'GET /api/reader/ink') return json(200, {});
|
||||||
|
if (route.startsWith('PUT /api/projects/')) {
|
||||||
|
return state.putStatus === 409
|
||||||
|
? json(409, { error: 'This study was changed on another device.', code: 'conflict', serverEdited: 999 })
|
||||||
|
: json(200, { id: url.split('/').pop().split('?')[0], title: 'x', lastEdited: 12345, chapterSummary: '' });
|
||||||
|
}
|
||||||
|
return json(404, { error: 'not mocked' });
|
||||||
|
}));
|
||||||
|
return { calls, state };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
resetLexiconCaches();
|
||||||
|
resetSyncBases();
|
||||||
|
switchStorageUser(null);
|
||||||
|
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ ok: false })));
|
||||||
|
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
|
||||||
|
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); });
|
||||||
|
|
||||||
|
async function renderApp() {
|
||||||
|
render(<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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Window title', () => {
|
||||||
|
test('names the page you are on, including the chapter in the reader', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
expect(document.title).toBe('My Studies · Bible Study');
|
||||||
|
await user.click(screen.getByRole('button', { name: /reading plans/i }));
|
||||||
|
await waitFor(() => expect(document.title).toBe('Reading Plans · Bible Study'));
|
||||||
|
await user.click(screen.getByRole('button', { name: /read bible/i }));
|
||||||
|
await waitFor(() => expect(document.title).toMatch(/^[A-Za-z0-9 ]+ \d+ · Bible Study$/));
|
||||||
|
});
|
||||||
|
});
|
||||||
+151
-27
@@ -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,49 @@ 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);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// The browser tab and history show where you are ("Romans 8 · Bible Study", "Luke — Outline · Bible Study")
|
||||||
|
useEffect(() => {
|
||||||
|
const names = {
|
||||||
|
home: 'My Studies', setup: 'Chunks', study: 'Study', outline: 'Outline', script: 'Script', export: 'Export',
|
||||||
|
import: 'Import', pronunciation: 'Pronunciation Guide', settings: 'Settings', admin: 'Admin', 'reading-plans': 'Reading Plans',
|
||||||
|
};
|
||||||
|
let page = names[currentPage] ?? '';
|
||||||
|
if (currentPage === 'reader') {
|
||||||
|
const book = bookOptions.find((b) => b.abbrev === reader.readerBookAbbrev)?.name ?? '';
|
||||||
|
page = `${book} ${reader.readerChapter}`.trim();
|
||||||
|
} else if (project?.title && ['setup', 'study', 'outline', 'script', 'export', 'pronunciation'].includes(currentPage)) {
|
||||||
|
page = `${project.title} — ${page}`;
|
||||||
|
}
|
||||||
|
document.title = page ? `${page} · Bible Study` : 'Bible Study';
|
||||||
|
}, [currentPage, project?.title, reader.readerBookAbbrev, reader.readerChapter]);
|
||||||
|
|
||||||
|
// Back/Forward move between pages instead of leaving the app. (Not for ?share= links: that page is separate.)
|
||||||
|
useHistoryNav({
|
||||||
|
page: currentPage,
|
||||||
|
setPage: setCurrentPage,
|
||||||
|
hasProject: !!project,
|
||||||
|
enabled: !new URLSearchParams(window.location.search).has('share'),
|
||||||
|
onArrive: (page) => { if (page === 'home') setProjectIndex(loadProjectIndex()); },
|
||||||
|
});
|
||||||
|
|
||||||
// Startup: migrate old keys and load index
|
// Startup: migrate old keys and load index
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
migrateOldStorageKeys();
|
migrateOldStorageKeys();
|
||||||
@@ -109,10 +162,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 +210,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 +335,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 +371,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 +393,7 @@ const App = () => {
|
|||||||
saveStatus, setSaveStatus,
|
saveStatus, setSaveStatus,
|
||||||
syncStatus,
|
syncStatus,
|
||||||
retrySync,
|
retrySync,
|
||||||
|
syncConflict, keepMyVersion, useServerVersion,
|
||||||
// Home
|
// Home
|
||||||
projectIndex,
|
projectIndex,
|
||||||
...library,
|
...library,
|
||||||
@@ -303,6 +406,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 +434,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 +496,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 +506,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 +514,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
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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,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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 <b>');
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}` : ''}`;
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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(', ');
|
||||||
|
}
|
||||||
@@ -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('');
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
Reference in new issue
Block a user