Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
820886e3ab | ||
|
|
d273c8cb3e | ||
|
|
b4f038b185 | ||
|
|
d13f55faf7 |
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,320 @@
|
|||||||
|
// 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('Outline: per chunk, details, print and share', () => {
|
||||||
|
async function openOutline() {
|
||||||
|
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);
|
||||||
|
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('each chunk has its own outline, separate from the whole study\'s', async () => {
|
||||||
|
const user = await openOutline();
|
||||||
|
const picker = screen.getByRole('combobox', { name: 'Outline for' });
|
||||||
|
await user.selectOptions(picker, picker.querySelectorAll('option')[1]); // the chunk
|
||||||
|
expect(await screen.findByText(/no outline yet for/i)).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: /start from the verses/i }));
|
||||||
|
const list = await screen.findByRole('list', { name: 'Outline' });
|
||||||
|
expect(within(list).getAllByRole('textbox')).toHaveLength(2); // one point per verse
|
||||||
|
expect(within(list).getByText(/In the beginning God created/)).toBeInTheDocument(); // with its words
|
||||||
|
await user.selectOptions(screen.getByRole('combobox', { name: 'Outline for' }), 'study');
|
||||||
|
expect(await screen.findByText(/^No outline yet$/)).toBeInTheDocument(); // the whole study is untouched
|
||||||
|
expect(screen.getByRole('option', { name: /· 2/ })).toBeInTheDocument(); // the chunk shows its count
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a point can be given a type and minutes, and the total shows', 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.' }), 'The story');
|
||||||
|
await user.click(screen.getByRole('button', { name: /details for i\./i }));
|
||||||
|
await user.selectOptions(screen.getByLabelText('Type'), 'illustration');
|
||||||
|
await user.type(screen.getByLabelText('Minutes'), '5');
|
||||||
|
expect(await screen.findByText('About 5 minutes')).toBeInTheDocument();
|
||||||
|
await user.type(screen.getByRole('textbox', { name: 'Notes for I.' }), 'Tell it slowly');
|
||||||
|
await user.click(screen.getByRole('button', { name: /details for i\./i })); // close: the note shows as a line
|
||||||
|
expect(await screen.findByText('Tell it slowly')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Illustration')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('"Add verse text" puts the passage\'s words into the point', async () => {
|
||||||
|
const user = await openOutline();
|
||||||
|
await user.click(screen.getByRole('button', { name: /start blank/i }));
|
||||||
|
await user.click(await screen.findByRole('button', { name: /details for i\./i }));
|
||||||
|
await user.type(screen.getByLabelText('Passage'), 'Titus 1:1');
|
||||||
|
await user.click(screen.getByRole('button', { name: /add verse text/i }));
|
||||||
|
expect(await screen.findByText(/In the beginning God created/)).toBeInTheDocument();
|
||||||
|
await user.clear(screen.getByLabelText('Passage'));
|
||||||
|
await user.type(screen.getByLabelText('Passage'), 'nonsense');
|
||||||
|
await user.click(screen.getByRole('button', { name: /replace verse text/i }));
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent(/type a passage/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Share copies the outline when there is no share sheet; Print opens a page of it', async () => {
|
||||||
|
const user = await openOutline();
|
||||||
|
await user.click(screen.getByRole('button', { name: /start from my chunks/i }));
|
||||||
|
await screen.findByRole('list', { name: 'Outline' });
|
||||||
|
await user.click(screen.getByRole('button', { name: /^share$/i }));
|
||||||
|
expect(await screen.findByText(/outline copied/i)).toBeInTheDocument();
|
||||||
|
const doc = { write: vi.fn(), close: vi.fn() };
|
||||||
|
vi.spyOn(window, 'open').mockReturnValue({ document: doc, focus: vi.fn(), print: vi.fn() });
|
||||||
|
await user.click(screen.getByRole('button', { name: /print \/ pdf/i }));
|
||||||
|
expect(doc.write.mock.calls[0][0]).toContain('class="outline"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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,335 @@
|
|||||||
|
// The Outline stage: the shape of the message as a real outline — main points (I.), sub-points (A.),
|
||||||
|
// and deeper (1., a.). There is one outline for the whole study and one for each chunk (pick which at
|
||||||
|
// the top). A point can be an illustration, application, quote…, carry minutes, a longer note, a verse's
|
||||||
|
// words, and cross-references or word studies from its chunk. Print it, share it, or take it as a file.
|
||||||
|
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 { buildChapterSummary } from '../utils/projectStorage.js';
|
||||||
|
import { compactPassages } from '../utils/passageSummary.js';
|
||||||
|
import { attachableFromChunk, fetchPassageText } from '../utils/passageText.js';
|
||||||
|
import { downloadOutlineDocx, downloadOutlineMarkdown, printOutline, shareOutline } from '../utils/outlineExport.js';
|
||||||
|
import {
|
||||||
|
KINDS, STUDY, addAfter, addChild, addRoot, chunkOf, countNodes, flatten, getNode, indent, insertAt, kindLabel, locate,
|
||||||
|
moveNode, newNode, outdent, outlineFor, outlineToText, removeNode, seedFromChunks, seedFromVerses, timeSummary, updateNode, withOutline,
|
||||||
|
} 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';
|
||||||
|
const field = '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)]';
|
||||||
|
const chip = 'rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[11px] text-[var(--r-sub)]';
|
||||||
|
|
||||||
|
// A text box that grows with what is typed, so a long point never hides behind a scroll bar.
|
||||||
|
function AutoText({ value, onChange, className, ...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={className} {...rest} />;
|
||||||
|
}
|
||||||
|
const pointBox = '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';
|
||||||
|
|
||||||
|
export default function OutlinePage() {
|
||||||
|
const { project, updateProject, setCurrentPage } = useApp();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [chosen, setChosen] = useState(STUDY);
|
||||||
|
const scope = chosen === STUDY || chunkOf(project, chosen) ? chosen : STUDY; // a deleted chunk falls back to the whole study
|
||||||
|
const scopeChunk = scope === STUDY ? null : chunkOf(project, scope);
|
||||||
|
const tree = outlineFor(project, scope);
|
||||||
|
const rows = flatten(tree);
|
||||||
|
const [pendingFocus, setPendingFocus] = useState(null);
|
||||||
|
const [metaOpen, setMetaOpen] = useState(() => new Set()); // points whose detail fields are showing
|
||||||
|
const [lookup, setLookup] = useState({ id: null, busy: false, error: '' });
|
||||||
|
|
||||||
|
// The one way every change is made, always from the latest outline (typing is fast).
|
||||||
|
const edit = (fn) => updateProject((current) => withOutline(current, scope, fn(outlineFor(current, scope))));
|
||||||
|
const patch = (id, change) => edit((t) => updateNode(t, id, change));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingFocus) return;
|
||||||
|
document.querySelector(`[data-outline-id="${pendingFocus}"]`)?.focus();
|
||||||
|
setPendingFocus(null);
|
||||||
|
}, [pendingFocus, tree]);
|
||||||
|
|
||||||
|
// Every chunk, named by passage, for the scope picker and the "linked chunk" choice
|
||||||
|
const chunkChoices = [];
|
||||||
|
(project?.chapters ?? []).forEach((chapter, chapterIndex) => chapter.chunks.forEach((chunk) => {
|
||||||
|
chunkChoices.push({ id: chunk.id, label: formatChunkReference(project, chapterIndex, chunk, '–'), count: countNodes(chunk.outline ?? []) });
|
||||||
|
}));
|
||||||
|
const scopeLabel = scope === STUDY ? 'Whole study' : chunkChoices.find((c) => c.id === scope)?.label ?? '';
|
||||||
|
const title = scope === STUDY ? project.title : `${project.title} — ${scopeLabel}`;
|
||||||
|
const subtitle = scope === STUDY ? compactPassages(buildChapterSummary(project)) : `${scopeLabel} · ${project.translation ?? 'BSB'}`;
|
||||||
|
const outline = { title, subtitle, tree };
|
||||||
|
|
||||||
|
const addable = scope === STUDY
|
||||||
|
? seedFromChunks(project, tree).length - tree.length
|
||||||
|
: seedFromVerses(project, scope, tree).length - tree.length;
|
||||||
|
const seedLabel = scope === STUDY ? 'Add my chunks' : 'Add the verses';
|
||||||
|
|
||||||
|
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 seed = () => {
|
||||||
|
edit((t) => (scope === STUDY ? seedFromChunks(project, t) : seedFromVerses(project, scope, t)));
|
||||||
|
toast(`Added ${addable} point${addable === 1 ? '' : 's'}`);
|
||||||
|
};
|
||||||
|
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); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- taking it out: copy, share, print, files
|
||||||
|
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 share = async () => {
|
||||||
|
const result = await shareOutline(outline);
|
||||||
|
if (result === 'copied') toast('Outline copied. Paste it into a message or email.');
|
||||||
|
else if (result === 'failed') toast("Couldn't share or copy the outline.", { tone: 'error' });
|
||||||
|
};
|
||||||
|
const print = () => { if (!printOutline(outline)) toast('Allow pop-ups for this site to print or save as PDF.', { tone: 'error' }); };
|
||||||
|
const word = async () => { try { await downloadOutlineDocx(outline); } catch { toast("Couldn't make the Word file.", { tone: 'error' }); } };
|
||||||
|
|
||||||
|
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; });
|
||||||
|
|
||||||
|
// Puts a passage's words into a point (the point's passage field says which).
|
||||||
|
const addVerseText = async (node) => {
|
||||||
|
setLookup({ id: node.id, busy: true, error: '' });
|
||||||
|
try {
|
||||||
|
const got = await fetchPassageText(node.ref, project.translation ?? 'BSB');
|
||||||
|
patch(node.id, { scripture: got });
|
||||||
|
setLookup({ id: null, busy: false, error: '' });
|
||||||
|
} catch (err) {
|
||||||
|
setLookup({ id: node.id, busy: false, error: err.message || 'Could not load those verses.' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StudioShell stage="outline">
|
||||||
|
<main className="mx-auto max-w-3xl px-4 py-8">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h1 className="text-xl font-medium">Outline</h1>
|
||||||
|
<p className="mt-1 text-sm text-[var(--r-sub)]">The shape of the message. Keep one for the whole study, and one for each chunk if you like.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||||
|
<label className="flex items-center gap-2 text-sm text-[var(--r-sub)]">Outline for
|
||||||
|
<select value={scope} onChange={(e) => setChosen(e.target.value)} aria-label="Outline for"
|
||||||
|
className="rounded-lg border border-[var(--r-line)] bg-[var(--r-bar)] px-2.5 py-1.5 text-sm text-[var(--r-text)]">
|
||||||
|
<option value={STUDY}>Whole study{countNodes(project.outline ?? []) ? ` · ${countNodes(project.outline)}` : ''}</option>
|
||||||
|
{chunkChoices.map((c) => <option key={c.id} value={c.id}>{c.label}{c.count ? ` · ${c.count}` : ''}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{timeSummary(tree) && <span className="text-sm text-[var(--r-sub)]">{timeSummary(tree)}</span>}
|
||||||
|
{scopeChunk && <button type="button" onClick={() => openChunk(scope)} className="text-sm text-[var(--r-accent)] hover:underline">Open this chunk in Study</button>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-5 flex flex-wrap items-center 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={seed} disabled={addable <= 0} className={btn}
|
||||||
|
title={scope === STUDY ? "Adds a main point for each chunk that isn't in the outline yet" : "Adds a point for each verse of this chunk, with its words"}>
|
||||||
|
{seedLabel}{addable > 0 ? ` (${addable})` : ''}
|
||||||
|
</button>
|
||||||
|
<span className="mx-1 hidden h-6 w-px bg-[var(--r-line)] sm:block" aria-hidden="true" />
|
||||||
|
<button type="button" onClick={share} disabled={tree.length === 0} className={btn}>Share</button>
|
||||||
|
<button type="button" onClick={print} disabled={tree.length === 0} className={btn}>Print / PDF</button>
|
||||||
|
<details className="relative">
|
||||||
|
<summary className={`${btn} cursor-pointer list-none ${tree.length === 0 ? 'pointer-events-none opacity-50' : ''}`}>More ▾</summary>
|
||||||
|
<div className="absolute left-0 z-20 mt-1 w-48 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] p-1 shadow-lg">
|
||||||
|
<button type="button" onClick={copy} className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-black/5">Copy as text</button>
|
||||||
|
<button type="button" onClick={word} className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-black/5">Download Word (.docx)</button>
|
||||||
|
<button type="button" onClick={() => downloadOutlineMarkdown(outline)} className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-black/5">Download Markdown</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</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{scope === STUDY ? '' : ` for ${scopeLabel}`}</p>
|
||||||
|
<p className="mx-auto mt-2 max-w-md text-sm text-[var(--r-sub)]">
|
||||||
|
{scope === STUDY
|
||||||
|
? 'Start from your chunks to get a main point for each passage you studied, or start blank and write your own.'
|
||||||
|
: 'Start from the verses to get a point for each one, with its words, or start blank and write your own.'}
|
||||||
|
</p>
|
||||||
|
<div className="mt-5 flex flex-wrap justify-center gap-2">
|
||||||
|
<button type="button" onClick={seed} disabled={addable <= 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">
|
||||||
|
{scope === STUDY ? 'Start from my chunks' : 'Start from the verses'}
|
||||||
|
</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);
|
||||||
|
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);
|
||||||
|
const attachSource = chunkOf(project, node.chunkId ?? scopeChunk?.id);
|
||||||
|
const attached = new Set((node.attachments ?? []).map((a) => a.id));
|
||||||
|
const available = attachableFromChunk(attachSource).filter((a) => !attached.has(a.key));
|
||||||
|
const mins = Number.isFinite(node.minutes) && node.minutes > 0;
|
||||||
|
const noteLine = (node.note ?? '').trim().split('\n')[0];
|
||||||
|
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">
|
||||||
|
<AutoText
|
||||||
|
value={node.text} data-outline-id={node.id} className={pointBox}
|
||||||
|
aria-label={`Point ${label}`} placeholder={depth === 0 ? 'Main point' : 'Sub-point'}
|
||||||
|
onChange={(value) => patch(node.id, { text: value })}
|
||||||
|
onKeyDown={(e) => onKeyDown(e, node.id, node.text, hasChildren)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* What the point carries, at a glance */}
|
||||||
|
{(node.kind || mins || node.ref || linked) && (
|
||||||
|
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 px-2 text-xs text-[var(--r-sub)]">
|
||||||
|
{node.kind && <span className={`${chip} border-[var(--r-accent)] text-[var(--r-accent)]`}>{kindLabel(node.kind)}</span>}
|
||||||
|
{mins && <span>{node.minutes} min</span>}
|
||||||
|
{node.ref && <span>{node.ref}</span>}
|
||||||
|
{linked && node.chunkId !== scope && <button type="button" onClick={() => openChunk(node.chunkId)} className="text-[var(--r-accent)] hover:underline">Open chunk in Study</button>}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{noteLine && !showMeta && <p className="truncate px-2 text-xs italic text-[var(--r-sub)]">{noteLine}</p>}
|
||||||
|
{node.scripture?.text && !showMeta && (
|
||||||
|
<blockquote className="mx-2 mt-1 border-l-2 border-[var(--r-line)] pl-2.5 font-serif text-sm italic text-[var(--r-sub)]">“{node.scripture.text.length > 220 ? `${node.scripture.text.slice(0, 220)}…` : node.scripture.text}” <span className="not-italic">— {node.scripture.ref}</span></blockquote>
|
||||||
|
)}
|
||||||
|
{(node.attachments ?? []).length > 0 && !showMeta && (
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1 px-2">
|
||||||
|
{node.attachments.map((a) => <span key={a.id} className={chip} title={a.detail}>{a.type === 'word' ? 'Word' : 'See'}: {a.label}</span>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showMeta && (
|
||||||
|
<div className="mb-2 mt-1 space-y-3 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] p-3">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-[1fr_6rem]">
|
||||||
|
<label className="text-xs text-[var(--r-sub)]">Type
|
||||||
|
<select value={node.kind ?? ''} onChange={(e) => patch(node.id, { kind: e.target.value })} className={field}>
|
||||||
|
{KINDS.map(([id, text]) => <option key={id} value={id}>{text}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-[var(--r-sub)]">Minutes
|
||||||
|
<input type="number" min="0" max="240" inputMode="numeric" value={node.minutes ?? ''}
|
||||||
|
onChange={(e) => patch(node.id, { minutes: e.target.value === '' ? null : Math.max(0, Math.min(240, Math.round(Number(e.target.value)))) })} className={field} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="block text-xs text-[var(--r-sub)]">Notes
|
||||||
|
<AutoText value={node.note ?? ''} onChange={(value) => patch(node.id, { note: value })} placeholder="Anything to remember when you speak or write this point"
|
||||||
|
className={`${field} min-h-[4.5rem] resize-none overflow-hidden`} aria-label={`Notes for ${label}`} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-[var(--r-sub)]">Passage
|
||||||
|
<input value={node.ref ?? ''} onChange={(e) => patch(node.id, { ref: e.target.value })} placeholder="e.g. Titus 1:5–9" className={field} />
|
||||||
|
</label>
|
||||||
|
<div className="mt-1.5 flex items-center gap-2">
|
||||||
|
<button type="button" disabled={!node.ref?.trim() || (lookup.busy && lookup.id === node.id)} onClick={() => addVerseText(node)} className={`${btn} !px-3 !py-1 text-xs`}>
|
||||||
|
{lookup.busy && lookup.id === node.id ? 'Loading…' : node.scripture ? 'Replace verse text' : 'Add verse text'}
|
||||||
|
</button>
|
||||||
|
{node.scripture && <button type="button" onClick={() => patch(node.id, { scripture: null })} className="text-xs text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]">Remove</button>}
|
||||||
|
</div>
|
||||||
|
{lookup.id === node.id && lookup.error && <p role="alert" className="mt-1 text-xs text-[var(--r-danger,#b42318)]">{lookup.error}</p>}
|
||||||
|
</div>
|
||||||
|
<label className="text-xs text-[var(--r-sub)]">Linked chunk
|
||||||
|
<select value={node.chunkId ?? ''} onChange={(e) => patch(node.id, {
|
||||||
|
chunkId: e.target.value || null,
|
||||||
|
...(e.target.value && !node.ref ? { ref: chunkChoices.find((c) => c.id === e.target.value)?.label ?? '' } : {}),
|
||||||
|
})} className={field}>
|
||||||
|
<option value="">None</option>
|
||||||
|
{chunkChoices.map((c) => <option key={c.id} value={c.id}>{c.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{node.scripture?.text && (
|
||||||
|
<blockquote className="border-l-2 border-[var(--r-line)] pl-2.5 font-serif text-sm italic text-[var(--r-sub)]">“{node.scripture.text}” <span className="not-italic">— {node.scripture.ref} ({node.scripture.translation})</span></blockquote>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-[var(--r-sub)]">Attach from {attachSource ? 'its chunk' : 'a chunk'}</p>
|
||||||
|
{(node.attachments ?? []).length > 0 && (
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{node.attachments.map((a) => (
|
||||||
|
<span key={a.id} className={`${chip} inline-flex items-center gap-1`} title={a.detail}>
|
||||||
|
{a.type === 'word' ? 'Word' : 'See'}: {a.label}
|
||||||
|
<button type="button" aria-label={`Remove ${a.label}`} onClick={() => patch(node.id, { attachments: node.attachments.filter((x) => x.id !== a.id) })} className="hover:text-[var(--r-danger,#b42318)]">×</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!attachSource ? (
|
||||||
|
<p className="mt-1 text-xs text-[var(--r-sub)]">Link a chunk above to attach its cross-references and word studies.</p>
|
||||||
|
) : available.length === 0 ? (
|
||||||
|
<p className="mt-1 text-xs text-[var(--r-sub)]">{(node.attachments ?? []).length ? 'Everything from this chunk is attached.' : 'This chunk has no cross-references or word studies yet. Add some in the Study stage.'}</p>
|
||||||
|
) : (
|
||||||
|
<select value="" aria-label={`Attach to ${label}`} className={field}
|
||||||
|
onChange={(e) => {
|
||||||
|
const pick = available.find((a) => a.key === e.target.value);
|
||||||
|
if (pick) patch(node.id, { attachments: [...(node.attachments ?? []), { id: pick.key, type: pick.type, label: pick.label, detail: pick.detail }] });
|
||||||
|
}}>
|
||||||
|
<option value="">Choose a cross-reference or word…</option>
|
||||||
|
{available.map((a) => <option key={a.key} value={a.key}>{a.type === 'word' ? 'Word' : 'See'}: {a.label}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</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={`Details for ${label}`} aria-expanded={showMeta} title="Type, minutes, notes, verse text, attachments" 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 · ⋯ opens its details.
|
||||||
|
</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, kindLabel as outlineKindLabel } from './outlineLabels.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Study constants
|
// Study constants
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -187,6 +189,15 @@ 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 .kind { font-size: 0.75em; text-transform: uppercase; letter-spacing: 0.05em; color: #92400e; border: 1px solid #fcd34d; border-radius: 9999px; padding: 0 0.4em; margin-right: 0.3em; }
|
||||||
|
.outline .o-note { margin: 0.2rem 0 0 2rem; color: #334155; font-size: 0.95em; }
|
||||||
|
.outline .o-quote { margin: 0.3rem 0 0.3rem 2rem; padding-left: 0.75rem; border-left: 3px solid #cbd5e1; font-style: italic; }
|
||||||
|
.outline .o-attach { margin-left: 2rem; font-size: 0.85em; color: #64748b; }
|
||||||
|
.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 +208,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>${outlineTimeHtml(project.outline)}${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) => {
|
||||||
@@ -256,6 +272,7 @@ export function buildExportHtml(project) {
|
|||||||
<section class="chunk">
|
<section class="chunk">
|
||||||
<div class="scripture-ref">${escHtml(scripture)}</div>
|
<div class="scripture-ref">${escHtml(scripture)}</div>
|
||||||
${versesText}
|
${versesText}
|
||||||
|
${(chunk.outline ?? []).length > 0 ? `<div class="outline"><strong>OUTLINE:</strong>${outlineListHtml(chunk.outline, 0)}</div>` : ''}
|
||||||
<div class="oia">
|
<div class="oia">
|
||||||
<div class="oia-section"><strong>OBSERVATION:</strong><p>${observation}</p></div>
|
<div class="oia-section"><strong>OBSERVATION:</strong><p>${observation}</p></div>
|
||||||
<div class="oia-section"><strong>INTERPRETATION:</strong><p>${interpretation}</p></div>
|
<div class="oia-section"><strong>INTERPRETATION:</strong><p>${interpretation}</p></div>
|
||||||
@@ -284,12 +301,75 @@ 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 outlineTimeHtml(tree) {
|
||||||
|
const total = flattenMinutes(tree);
|
||||||
|
return total > 0 ? `<p class="ref">About ${total} minute${total === 1 ? '' : 's'}</p>` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenMinutes(nodes) {
|
||||||
|
return (nodes ?? []).reduce((sum, n) => sum + (Number.isFinite(n.minutes) ? n.minutes : 0) + flattenMinutes(n.children), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function outlineListHtml(nodes, depth) {
|
||||||
|
const one = (n, i) => {
|
||||||
|
const kind = n.kind ? `<span class="kind">${escHtml(outlineKindLabel(n.kind))}</span> ` : '';
|
||||||
|
const mins = Number.isFinite(n.minutes) && n.minutes > 0 ? ` <span class="ref">· ${n.minutes} min</span>` : '';
|
||||||
|
const note = (n.note ?? '').trim() ? `<div class="o-note">${escHtml(n.note.trim()).replace(/\n/g, '<br />')}</div>` : '';
|
||||||
|
const quote = n.scripture?.text ? `<blockquote class="o-quote">“${escHtml(n.scripture.text)}” — ${escHtml(n.scripture.ref)}</blockquote>` : '';
|
||||||
|
const attached = (n.attachments ?? []).length > 0
|
||||||
|
? `<div class="o-attach">${n.attachments.map((a) => `${a.type === 'word' ? 'Word' : 'See'}: ${escHtml(a.label)}${a.detail ? ` — ${escHtml(a.detail)}` : ''}`).join(' · ')}</div>` : '';
|
||||||
|
return `<li><span class="lbl">${escHtml(outlineLabel(depth, i))}</span>${kind}${escHtml((n.text ?? '').trim() || '(untitled)')}${n.ref ? ` <span class="ref">(${escHtml(n.ref)})</span>` : ''}${mins}${note}${quote}${attached}${n.children?.length ? outlineListHtml(n.children, depth + 1) : ''}</li>`;
|
||||||
|
};
|
||||||
|
return `<ul>${nodes.map(one).join('')}</ul>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A page of just one outline (for printing or saving as PDF): the title, the passage, the points.
|
||||||
|
export function buildOutlineHtml({ title, subtitle = '', tree }) {
|
||||||
|
const total = flattenMinutes(tree);
|
||||||
|
const style = `
|
||||||
|
body { font-family: Georgia, serif; color: #0f172a; margin: 0; padding: 40px; }
|
||||||
|
.page { max-width: 760px; margin: auto; }
|
||||||
|
h1 { margin: 0 0 0.25rem; font-size: 1.7rem; }
|
||||||
|
.sub { color: #64748b; margin: 0 0 1.5rem; }
|
||||||
|
.outline ul { list-style: none; margin: 0.25rem 0 0.25rem 1.6rem; padding: 0; }
|
||||||
|
.outline > ul { margin-left: 0; }
|
||||||
|
.outline li { margin: 0.45rem 0; line-height: 1.5; }
|
||||||
|
.outline > ul > li { margin-top: 0.9rem; font-weight: 600; }
|
||||||
|
.outline > ul > li li { font-weight: 400; }
|
||||||
|
.lbl { display: inline-block; min-width: 2.1rem; font-weight: 700; }
|
||||||
|
.ref { color: #64748b; font-size: 0.9em; font-weight: 400; }
|
||||||
|
.kind { font-size: 0.72em; text-transform: uppercase; letter-spacing: 0.05em; color: #92400e; border: 1px solid #fcd34d; border-radius: 9999px; padding: 0 0.4em; margin-right: 0.3em; font-weight: 400; }
|
||||||
|
.o-note { margin: 0.2rem 0 0 2.1rem; color: #334155; font-size: 0.95em; font-weight: 400; }
|
||||||
|
.o-quote { margin: 0.3rem 0 0.3rem 2.1rem; padding-left: 0.75rem; border-left: 3px solid #cbd5e1; font-style: italic; font-weight: 400; }
|
||||||
|
.o-attach { margin-left: 2.1rem; font-size: 0.85em; color: #64748b; font-weight: 400; }
|
||||||
|
@media print { body { padding: 0; } li { break-inside: avoid; } }
|
||||||
|
`;
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>${escHtml(title)} — Outline</title>
|
||||||
|
<style>${style}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<h1>${escHtml(title)}</h1>
|
||||||
|
<p class="sub">${escHtml([subtitle, total > 0 ? `About ${total} minute${total === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · '))}</p>
|
||||||
|
<div class="outline">${outlineListHtml(tree, 0)}</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
export function wordTableHtml(rows) {
|
export function wordTableHtml(rows) {
|
||||||
return `
|
return `
|
||||||
<table>
|
<table>
|
||||||
@@ -341,7 +421,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,8 @@ import {
|
|||||||
WidthType,
|
WidthType,
|
||||||
} from 'docx';
|
} from 'docx';
|
||||||
import { formatChunkReference, getChunkVerseEntries, htmlToPlainText } from './bibleUtils.jsx';
|
import { formatChunkReference, getChunkVerseEntries, htmlToPlainText } from './bibleUtils.jsx';
|
||||||
|
import { detailLines, flatten, timeSummary } from './outline.js';
|
||||||
|
import { kindLabel } from './outlineLabels.js';
|
||||||
|
|
||||||
export function createParagraphsFromText(text) {
|
export function createParagraphsFromText(text) {
|
||||||
return text
|
return text
|
||||||
@@ -72,6 +74,37 @@ export function sectionLabel(text) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The paragraphs of an outline: each point indented by level, with its note, verse and attachments under it.
|
||||||
|
export const outlineParagraphs = (tree) => flatten(tree).flatMap(({ node, depth, label }) => [
|
||||||
|
new Paragraph({
|
||||||
|
indent: { left: 420 * depth, hanging: 420 },
|
||||||
|
spacing: { after: 60 },
|
||||||
|
children: [
|
||||||
|
new TextRun({ text: `${label}\t`, bold: true, color: DOCX_ACCENT }),
|
||||||
|
...(node.kind ? [new TextRun({ text: `${kindLabel(node.kind)}: `, italics: true, color: '92400E' })] : []),
|
||||||
|
new TextRun({ text: (node.text ?? '').trim() || '(untitled)' }),
|
||||||
|
...(node.ref ? [new TextRun({ text: ` (${node.ref})`, italics: true, color: '7A7060' })] : []),
|
||||||
|
...(Number.isFinite(node.minutes) && node.minutes > 0 ? [new TextRun({ text: ` · ${node.minutes} min`, color: '7A7060' })] : []),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
...detailLines(node).map((line) => new Paragraph({
|
||||||
|
indent: { left: 420 * depth + 420 },
|
||||||
|
spacing: { after: 40 },
|
||||||
|
children: [new TextRun({ text: line, italics: true, color: '4B5563', size: 20 })],
|
||||||
|
})),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Just one outline, as its own Word document.
|
||||||
|
export async function buildOutlineDocxBlob({ title, subtitle = '', tree }) {
|
||||||
|
const children = [
|
||||||
|
new Paragraph({ text: title, heading: HeadingLevel.TITLE }),
|
||||||
|
new Paragraph({ children: [new TextRun({ text: [subtitle, timeSummary(tree)].filter(Boolean).join(' · '), italics: true, color: '7A7060' })], spacing: { after: 300 } }),
|
||||||
|
...outlineParagraphs(tree),
|
||||||
|
];
|
||||||
|
const doc = new Document({ styles: DOCX_STYLES, sections: [{ children }] });
|
||||||
|
return Packer.toBlob(doc);
|
||||||
|
}
|
||||||
|
|
||||||
export async function buildStudyDocxBlob(project, chapterSummary) {
|
export async function buildStudyDocxBlob(project, chapterSummary) {
|
||||||
const children = [
|
const children = [
|
||||||
new Paragraph({ text: project.title, heading: HeadingLevel.TITLE }),
|
new Paragraph({ text: project.title, heading: HeadingLevel.TITLE }),
|
||||||
@@ -89,6 +122,13 @@ 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 } }));
|
||||||
|
if (timeSummary(project.outline)) children.push(new Paragraph({ children: [new TextRun({ text: timeSummary(project.outline), italics: true, color: '7A7060' })], spacing: { after: 120 } }));
|
||||||
|
children.push(...outlineParagraphs(project.outline));
|
||||||
|
}
|
||||||
|
|
||||||
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}`,
|
||||||
@@ -137,6 +177,11 @@ export async function buildStudyDocxBlob(project, chapterSummary) {
|
|||||||
}));
|
}));
|
||||||
children.push(new Paragraph({ text: '', spacing: { after: 120 } }));
|
children.push(new Paragraph({ text: '', spacing: { after: 120 } }));
|
||||||
|
|
||||||
|
if ((chunk.outline ?? []).length > 0) {
|
||||||
|
children.push(sectionLabel('Outline'));
|
||||||
|
children.push(...outlineParagraphs(chunk.outline));
|
||||||
|
}
|
||||||
|
|
||||||
if (chunk.generalNotes?.trim()) {
|
if (chunk.generalNotes?.trim()) {
|
||||||
children.push(sectionLabel('Background / General Notes'));
|
children.push(sectionLabel('Background / General Notes'));
|
||||||
children.push(...createParagraphsFromText(chunk.generalNotes));
|
children.push(...createParagraphsFromText(chunk.generalNotes));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
getChunkVerseEntries,
|
getChunkVerseEntries,
|
||||||
htmlToPlainText,
|
htmlToPlainText,
|
||||||
} from './bibleUtils.jsx';
|
} from './bibleUtils.jsx';
|
||||||
|
import { outlineToMarkdown, timeSummary } from './outline.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Export / prompt builders (exported for testing)
|
// Export / prompt builders (exported for testing)
|
||||||
@@ -42,13 +43,16 @@ export function buildMarkdownExport(project) {
|
|||||||
return `- ${summary}${definition}`;
|
return `- ${summary}${definition}`;
|
||||||
}).join('\n')}\n\n`;
|
}).join('\n')}\n\n`;
|
||||||
|
|
||||||
return `### ${scripture}\n\n${versesText}\n\n${generalNotes ? `**Background / Notes**\n\n${generalNotes}\n\n` : ''}**Observation**\n\n${observation}\n\n**Interpretation**\n\n${interpretation}\n\n**Application**\n\n${application}\n\n${crossRefsLine}${greekSection}`;
|
const chunkOutline = (chunk.outline ?? []).length > 0 ? `**Outline**\n\n${outlineToMarkdown(chunk.outline)}\n\n` : '';
|
||||||
|
|
||||||
|
return `### ${scripture}\n\n${versesText}\n\n${chunkOutline}${generalNotes ? `**Background / Notes**\n\n${generalNotes}\n\n` : ''}**Observation**\n\n${observation}\n\n**Interpretation**\n\n${interpretation}\n\n**Application**\n\n${application}\n\n${crossRefsLine}${greekSection}`;
|
||||||
}).join('---\n\n');
|
}).join('---\n\n');
|
||||||
|
|
||||||
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${timeSummary(project.outline) ? `*${timeSummary(project.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,236 @@
|
|||||||
|
// 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, getChunkVerseEntries, makeId } from './bibleUtils.jsx';
|
||||||
|
import { KINDS, kindLabel, labelFor, toRoman } from './outlineLabels.js';
|
||||||
|
|
||||||
|
export { KINDS, kindLabel, labelFor, toRoman };
|
||||||
|
|
||||||
|
export const MAX_DEPTH = 5; // levels below the main points
|
||||||
|
|
||||||
|
// A point: its words, an optional passage and chunk link, plus a longer note, a kind, minutes,
|
||||||
|
// the text of a verse, and things attached from the chunks (cross-references, word studies).
|
||||||
|
export const newNode = (text = '', extra = {}) => ({
|
||||||
|
id: makeId(), text, ref: '', chunkId: null, note: '', kind: '', minutes: null, scripture: null, attachments: [], 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 minutesText = (m) => (Number.isFinite(m) && m > 0 ? `${m} min` : '');
|
||||||
|
|
||||||
|
// Total minutes written on the points (blank ones count as nothing)
|
||||||
|
export const totalMinutes = (tree) => flatten(tree).reduce((sum, { node }) => sum + (Number.isFinite(node.minutes) ? node.minutes : 0), 0);
|
||||||
|
|
||||||
|
// The headline of a point: "[Illustration] The words (passage) · 5 min"
|
||||||
|
const pointText = (n) => {
|
||||||
|
const kind = n.kind ? `[${kindLabel(n.kind)}] ` : '';
|
||||||
|
const mins = minutesText(n.minutes);
|
||||||
|
return `${kind}${(n.text ?? '').trim() || '(untitled)'}${(n.ref ?? '').trim() ? ` (${n.ref.trim()})` : ''}${mins ? ` · ${mins}` : ''}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The lines under a point's headline: its note, verse text and attachments.
|
||||||
|
export function detailLines(node) {
|
||||||
|
const lines = [];
|
||||||
|
for (const l of (node.note ?? '').split('\n')) if (l.trim()) lines.push(l.trim());
|
||||||
|
if (node.scripture?.text) lines.push(`“${node.scripture.text}” — ${node.scripture.ref}`);
|
||||||
|
for (const a of node.attachments ?? []) lines.push(`${a.type === 'word' ? 'Word' : 'See'}: ${a.label}${a.detail ? ` — ${a.detail}` : ''}`);
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const timeSummary = (tree) => {
|
||||||
|
const total = totalMinutes(tree);
|
||||||
|
return total > 0 ? `About ${total} minute${total === 1 ? '' : 's'}` : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function outlineToText(tree) {
|
||||||
|
return flatten(tree).flatMap(({ node, depth, label }) => [
|
||||||
|
`${' '.repeat(depth)}${label} ${pointText(node)}`,
|
||||||
|
...detailLines(node).map((l) => `${' '.repeat(depth)} ${l}`),
|
||||||
|
]).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function outlineToMarkdown(tree) {
|
||||||
|
return flatten(tree).flatMap(({ node, depth, label }) => [
|
||||||
|
`${' '.repeat(depth)}- **${label}** ${pointText(node)}`,
|
||||||
|
...detailLines(node).map((l) => `${' '.repeat(depth)} *${l}*`),
|
||||||
|
]).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];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- one outline per chunk ---------------------------------------------------
|
||||||
|
// A study has one outline for the whole message (project.outline) and each chunk can have its own
|
||||||
|
// (chunk.outline). `scope` is STUDY or a chunk's id.
|
||||||
|
export const STUDY = 'study';
|
||||||
|
|
||||||
|
export function chunkOf(project, chunkId) {
|
||||||
|
for (const chapter of project?.chapters ?? []) for (const chunk of chapter.chunks ?? []) if (chunk.id === chunkId) return chunk;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const outlineFor = (project, scope) => (scope === STUDY ? project?.outline ?? [] : chunkOf(project, scope)?.outline ?? []);
|
||||||
|
|
||||||
|
// A copy of the project with that scope's outline replaced.
|
||||||
|
export function withOutline(project, scope, tree) {
|
||||||
|
if (scope === STUDY) return { ...project, outline: tree };
|
||||||
|
return {
|
||||||
|
...project,
|
||||||
|
chapters: project.chapters.map((ch) => ({ ...ch, chunks: ch.chunks.map((c) => (c.id === scope ? { ...c, outline: tree } : c)) })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// One point per verse of the chunk, each carrying that verse's words, for verses not already in `tree`.
|
||||||
|
export function seedFromVerses(project, chunkId, tree = []) {
|
||||||
|
const have = new Set(flatten(tree).map((f) => f.node.scripture?.ref).filter(Boolean));
|
||||||
|
const added = [];
|
||||||
|
(project?.chapters ?? []).forEach((chapter, chapterIndex) => {
|
||||||
|
for (const chunk of chapter.chunks ?? []) {
|
||||||
|
if (chunk.id !== chunkId) continue;
|
||||||
|
for (const v of getChunkVerseEntries(project, chapterIndex, chunk)) {
|
||||||
|
const ref = `${chapter.book} ${v.chapter}:${v.number}`;
|
||||||
|
if (have.has(ref)) continue;
|
||||||
|
added.push(newNode('', { ref, scripture: { ref, text: v.text, translation: project.translation ?? 'BSB' } }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return [...tree, ...added];
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
import {
|
||||||
|
addAfter, addChild, addRoot, countNodes, flatten, getNode, indent, labelFor, moveNode, newNode, outdent,
|
||||||
|
STUDY, chunkOf, detailLines, insertAt, outlineFor, seedFromVerses, withOutline, locate, timeSummary, totalMinutes, 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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('richer points', () => {
|
||||||
|
const rich = [{
|
||||||
|
...n('a', 'Creation'), ref: 'Gen 1', kind: 'illustration', minutes: 5, note: 'Tell the story.\n\nKeep it short.',
|
||||||
|
scripture: { ref: 'Genesis 1:1', text: 'In the beginning', translation: 'BSB' },
|
||||||
|
attachments: [{ id: 'x', type: 'xref', label: 'John 1:1' }, { id: 'w', type: 'word', label: 'bara (H1254)', detail: 'to create' }],
|
||||||
|
children: [{ ...n('b', 'Light'), minutes: 3 }],
|
||||||
|
}];
|
||||||
|
test('text and markdown show the kind, minutes, note, verse and attachments', () => {
|
||||||
|
expect(outlineToText(rich)).toBe([
|
||||||
|
'I. [Illustration] Creation (Gen 1) · 5 min',
|
||||||
|
' Tell the story.',
|
||||||
|
' Keep it short.',
|
||||||
|
' “In the beginning” — Genesis 1:1',
|
||||||
|
' See: John 1:1',
|
||||||
|
' Word: bara (H1254) — to create',
|
||||||
|
' A. Light · 3 min',
|
||||||
|
].join('\n'));
|
||||||
|
expect(outlineToMarkdown(rich)).toContain('- **I.** [Illustration] Creation (Gen 1) · 5 min\n *Tell the story.*');
|
||||||
|
});
|
||||||
|
test('totals the minutes, and says nothing when there are none', () => {
|
||||||
|
expect(totalMinutes(rich)).toBe(8);
|
||||||
|
expect(timeSummary(rich)).toBe('About 8 minutes');
|
||||||
|
expect(timeSummary([n('z')])).toBe('');
|
||||||
|
});
|
||||||
|
test('new points start plain, and older ones without the new fields still work', () => {
|
||||||
|
expect(newNode('x')).toMatchObject({ note: '', kind: '', minutes: null, scripture: null, attachments: [] });
|
||||||
|
expect(detailLines(n('old'))).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('an outline for each chunk', () => {
|
||||||
|
const project = {
|
||||||
|
translation: 'BSB', outline: [n('whole')],
|
||||||
|
chapters: [{ book: 'Titus', bookAbbrev: 'TIT', chapter: '1', verses: [{ number: 1, text: 'One.' }, { number: 2, text: 'Two.' }, { number: 3, text: 'Three.' }], chunks: [
|
||||||
|
{ id: 'c1', startVerse: 1, endVerse: 2, observation: '', outline: [n('mine')] },
|
||||||
|
{ id: 'c2', startVerse: 3, endVerse: 3, observation: '' },
|
||||||
|
] }],
|
||||||
|
};
|
||||||
|
test('reads and replaces the outline of the whole study or of one chunk', () => {
|
||||||
|
expect(ids(outlineFor(project, STUDY))).toEqual(['whole']);
|
||||||
|
expect(ids(outlineFor(project, 'c1'))).toEqual(['mine']);
|
||||||
|
expect(outlineFor(project, 'c2')).toEqual([]);
|
||||||
|
const next = withOutline(project, 'c2', [n('new')]);
|
||||||
|
expect(ids(outlineFor(next, 'c2'))).toEqual(['new']);
|
||||||
|
expect(ids(outlineFor(next, 'c1'))).toEqual(['mine']);
|
||||||
|
expect(ids(outlineFor(next, STUDY))).toEqual(['whole']);
|
||||||
|
expect(outlineFor(project, 'c2')).toEqual([]); // the original is untouched
|
||||||
|
expect(ids(outlineFor(withOutline(project, STUDY, []), STUDY))).toEqual([]);
|
||||||
|
});
|
||||||
|
test('seeding from verses adds a point per verse with its words, and never twice', () => {
|
||||||
|
const t = seedFromVerses(project, 'c1', []);
|
||||||
|
expect(t.map((x) => [x.ref, x.scripture.text])).toEqual([['Titus 1:1', 'One.'], ['Titus 1:2', 'Two.']]);
|
||||||
|
expect(seedFromVerses(project, 'c1', t)).toHaveLength(2);
|
||||||
|
expect(seedFromVerses(project, 'c2', [])).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// Taking one outline out of the app: print or save as PDF, share it, or download it.
|
||||||
|
import { buildOutlineHtml } from './bibleUtils.jsx';
|
||||||
|
import { outlineToText } from './outline.js';
|
||||||
|
|
||||||
|
const slug = (s) => String(s).replace(/[^\w]+/g, '-').replace(/^-|-$/g, '') || 'outline';
|
||||||
|
|
||||||
|
function downloadBlob(blob, filename) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// What gets shared as text: a heading, then the outline.
|
||||||
|
export const outlineShareText = ({ title, subtitle = '', tree }) => `${title}${subtitle ? `\n${subtitle}` : ''}\n\n${outlineToText(tree)}`;
|
||||||
|
|
||||||
|
// Opens a print-ready page of just this outline. Returns false when the browser blocked the window.
|
||||||
|
export function printOutline({ title, subtitle, tree }) {
|
||||||
|
const win = window.open('', '_blank');
|
||||||
|
if (!win) return false;
|
||||||
|
win.document.write(buildOutlineHtml({ title, subtitle, tree }));
|
||||||
|
win.document.close();
|
||||||
|
win.onload = () => { win.focus(); win.print(); };
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uses the phone/tablet/computer share sheet when there is one (Messages, Mail, AirDrop…), otherwise
|
||||||
|
// copies the text. -> 'shared' | 'copied' | 'cancelled' | 'failed'
|
||||||
|
export async function shareOutline(outline) {
|
||||||
|
const text = outlineShareText(outline);
|
||||||
|
if (navigator.share) {
|
||||||
|
try {
|
||||||
|
await navigator.share({ title: `${outline.title} — Outline`, text });
|
||||||
|
return 'shared';
|
||||||
|
} catch (err) {
|
||||||
|
if (err?.name === 'AbortError') return 'cancelled';
|
||||||
|
// fall through to copying
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return 'copied';
|
||||||
|
} catch {
|
||||||
|
return 'failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadOutlineMarkdown({ title, subtitle, tree }) {
|
||||||
|
const text = `# ${title}\n\n${subtitle ? `*${subtitle}*\n\n` : ''}${tree.length ? outlineToText(tree) : ''}\n`;
|
||||||
|
downloadBlob(new Blob([text], { type: 'text/markdown;charset=utf-8' }), `${slug(title)}-outline.md`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadOutlineDocx(outline) {
|
||||||
|
const { buildOutlineDocxBlob } = await import('./docxExport.js'); // the Word library only loads when used
|
||||||
|
downloadBlob(await buildOutlineDocxBlob(outline), `${slug(outline.title)}-outline.docx`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||||
|
import { outlineShareText, printOutline, shareOutline } from './outlineExport.js';
|
||||||
|
import { buildOutlineHtml } from './bibleUtils.jsx';
|
||||||
|
|
||||||
|
const outline = {
|
||||||
|
title: 'Titus 1 — Elders', subtitle: 'Titus 1:5–9',
|
||||||
|
tree: [{ id: 'a', text: 'Appoint elders', ref: 'Titus 1:5', kind: 'application', minutes: 4, note: 'Why it matters', children: [{ id: 'b', text: 'Blameless', ref: '', children: [] }] }],
|
||||||
|
};
|
||||||
|
beforeEach(() => { vi.stubGlobal('navigator', { ...navigator, share: undefined, clipboard: { writeText: vi.fn(() => Promise.resolve()) } }); });
|
||||||
|
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); });
|
||||||
|
|
||||||
|
test('the share text has the title, passage and the outline', () => {
|
||||||
|
const text = outlineShareText(outline);
|
||||||
|
expect(text.startsWith('Titus 1 — Elders\nTitus 1:5–9\n\nI. [Application] Appoint elders (Titus 1:5) · 4 min')).toBe(true);
|
||||||
|
expect(text).toContain('A. Blameless');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses the system share sheet when there is one', async () => {
|
||||||
|
const share = vi.fn(() => Promise.resolve());
|
||||||
|
vi.stubGlobal('navigator', { share, clipboard: { writeText: vi.fn() } });
|
||||||
|
expect(await shareOutline(outline)).toBe('shared');
|
||||||
|
expect(share).toHaveBeenCalledWith(expect.objectContaining({ title: 'Titus 1 — Elders — Outline' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('closing the share sheet is not an error, and a broken one falls back to copying', async () => {
|
||||||
|
vi.stubGlobal('navigator', { share: vi.fn(() => Promise.reject(Object.assign(new Error('x'), { name: 'AbortError' }))), clipboard: { writeText: vi.fn() } });
|
||||||
|
expect(await shareOutline(outline)).toBe('cancelled');
|
||||||
|
const writeText = vi.fn(() => Promise.resolve());
|
||||||
|
vi.stubGlobal('navigator', { share: vi.fn(() => Promise.reject(new Error('nope'))), clipboard: { writeText } });
|
||||||
|
expect(await shareOutline(outline)).toBe('copied');
|
||||||
|
expect(writeText).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('without a share sheet it copies, and says so when even that fails', async () => {
|
||||||
|
expect(await shareOutline(outline)).toBe('copied');
|
||||||
|
vi.stubGlobal('navigator', { clipboard: { writeText: vi.fn(() => Promise.reject(new Error('denied'))) } });
|
||||||
|
expect(await shareOutline(outline)).toBe('failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('print writes a page of just the outline, and reports a blocked window', () => {
|
||||||
|
const doc = { write: vi.fn(), close: vi.fn() };
|
||||||
|
vi.spyOn(window, 'open').mockReturnValue({ document: doc, focus: vi.fn(), print: vi.fn() });
|
||||||
|
expect(printOutline(outline)).toBe(true);
|
||||||
|
const html = doc.write.mock.calls[0][0];
|
||||||
|
expect(html).toContain('Appoint elders');
|
||||||
|
expect(html).toContain('About 4 minutes');
|
||||||
|
window.open.mockReturnValue(null);
|
||||||
|
expect(printOutline(outline)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the printable page escapes text and shows notes', () => {
|
||||||
|
const html = buildOutlineHtml({ title: 'A <b>', tree: [{ id: 'x', text: '<i>hi</i>', ref: '', note: 'line', children: [] }] });
|
||||||
|
expect(html).toContain('A <b>');
|
||||||
|
expect(html).toContain('<i>hi</i>');
|
||||||
|
expect(html).toContain('class="o-note"');
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// 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)})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const KINDS = [
|
||||||
|
['', 'Point'], ['illustration', 'Illustration'], ['application', 'Application'],
|
||||||
|
['quote', 'Quote'], ['question', 'Question'], ['transition', 'Transition'],
|
||||||
|
];
|
||||||
|
export const kindLabel = (kind) => KINDS.find(([id]) => id === kind)?.[1] ?? '';
|
||||||
@@ -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,49 @@
|
|||||||
|
// "Titus 1:5–9" -> the words of those verses, for putting scripture inside an outline point.
|
||||||
|
import { matchBooks } from './passageRef.js';
|
||||||
|
import { CHAPTER_COUNTS, bookOptions } from './bibleUtils.jsx';
|
||||||
|
import { fetchChapter } from './bibleText.js';
|
||||||
|
|
||||||
|
export const MAX_VERSES = 40; // a whole chapter's worth is plenty to paste into a point
|
||||||
|
|
||||||
|
// -> { book, bookName, chapter, start, end } (start/end null = the whole chapter) or null. Accepts
|
||||||
|
// "Titus 1:5-9", "titus 1:5", "John 3" and a dash or en dash.
|
||||||
|
export function parsePassageRange(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})(?:\s*[-–—]\s*(\d{1,3}))?)?$/i);
|
||||||
|
if (!m) return null;
|
||||||
|
const [, name, chapterText, startText, endText] = m;
|
||||||
|
const book = matchBooks(name)[0];
|
||||||
|
const chapter = Number(chapterText);
|
||||||
|
if (!book || chapter < 1 || chapter > (CHAPTER_COUNTS[book.abbrev] ?? 1)) return null;
|
||||||
|
const start = startText ? Number(startText) : null;
|
||||||
|
const end = endText ? Number(endText) : start;
|
||||||
|
if (start != null && end < start) return null;
|
||||||
|
return { book: book.abbrev, bookName: bookOptions.find((b) => b.abbrev === book.abbrev)?.name ?? book.name, chapter, start, end };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatRange = (r) => `${r.bookName} ${r.chapter}${r.start == null ? '' : r.end === r.start ? `:${r.start}` : `:${r.start}–${r.end}`}`;
|
||||||
|
|
||||||
|
// -> { ref, text, translation }. Throws Error(message) when the passage can't be read or found.
|
||||||
|
export async function fetchPassageText(text, translation = 'BSB') {
|
||||||
|
const range = parsePassageRange(text);
|
||||||
|
if (!range) throw new Error('Type a passage like “Titus 1:5–9”.');
|
||||||
|
const { verses } = await fetchChapter(translation, range.book, range.chapter);
|
||||||
|
const picked = range.start == null ? verses : verses.filter((v) => v.number >= range.start && v.number <= range.end);
|
||||||
|
if (picked.length === 0) throw new Error('Those verses weren’t found in that chapter.');
|
||||||
|
if (picked.length > MAX_VERSES) throw new Error(`That's ${picked.length} verses — pick ${MAX_VERSES} or fewer.`);
|
||||||
|
return { ref: formatRange(range), text: picked.map((v) => v.text).join(' '), translation };
|
||||||
|
}
|
||||||
|
|
||||||
|
// What a chunk offers to attach to a point: its cross-references and the words studied in it.
|
||||||
|
// -> [{ key, type: 'xref' | 'word', label, detail? }]
|
||||||
|
export function attachableFromChunk(chunk) {
|
||||||
|
if (!chunk) return [];
|
||||||
|
const refs = (chunk.crossReferences ?? []).map((r) => ({ key: `x:${r}`, type: 'xref', label: r }));
|
||||||
|
const words = (chunk.greekWords ?? [])
|
||||||
|
.filter((w) => w.lexeme || w.strongNumber || w.query)
|
||||||
|
.map((w) => ({
|
||||||
|
key: `w:${w.strongNumber || w.lexeme || w.query}`, type: 'word',
|
||||||
|
label: [w.lexeme, w.strongNumber && `(${w.strongNumber})`].filter(Boolean).join(' ') || w.query,
|
||||||
|
detail: w.shortDefinition && !/^(No definition|Lookup failed)/.test(w.shortDefinition) ? w.shortDefinition : undefined,
|
||||||
|
}));
|
||||||
|
return [...refs, ...words];
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||||
|
import { attachableFromChunk, fetchPassageText, parsePassageRange } from './passageText.js';
|
||||||
|
|
||||||
|
const chapter = { chapter: { content: [1, 2, 3, 4, 5].map((n) => ({ type: 'verse', number: n, content: [`Verse ${n} text.`] })) } };
|
||||||
|
beforeEach(() => vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(chapter) }))));
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
test('reads a verse, a range, or a whole chapter', () => {
|
||||||
|
expect(parsePassageRange('Titus 1:5')).toMatchObject({ book: 'TIT', chapter: 1, start: 5, end: 5 });
|
||||||
|
expect(parsePassageRange('titus 1:5-9')).toMatchObject({ start: 5, end: 9 });
|
||||||
|
expect(parsePassageRange('1 Cor 13:4–7')).toMatchObject({ book: '1CO', chapter: 13, start: 4, end: 7 });
|
||||||
|
expect(parsePassageRange('John 3')).toMatchObject({ chapter: 3, start: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects things that are not passages, or backwards ranges', () => {
|
||||||
|
expect(parsePassageRange('hello')).toBeNull();
|
||||||
|
expect(parsePassageRange('Titus 9:1')).toBeNull();
|
||||||
|
expect(parsePassageRange('Titus 1:9-5')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fetches just the verses asked for, and names the passage', async () => {
|
||||||
|
const got = await fetchPassageText('Titus 1:2-3');
|
||||||
|
expect(got).toEqual({ ref: 'Titus 1:2–3', text: 'Verse 2 text. Verse 3 text.', translation: 'BSB' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('says so when the verses are not there, or the passage is not understood', async () => {
|
||||||
|
await expect(fetchPassageText('Titus 1:50')).rejects.toThrow(/weren’t found/);
|
||||||
|
await expect(fetchPassageText('nonsense')).rejects.toThrow(/Type a passage/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('offers a chunk\'s cross-references and studied words to attach', () => {
|
||||||
|
const chunk = {
|
||||||
|
crossReferences: ['John 1:1'],
|
||||||
|
greekWords: [
|
||||||
|
{ strongNumber: 'G1401', lexeme: 'δοῦλος', shortDefinition: 'a slave' },
|
||||||
|
{ query: 'xyz', shortDefinition: 'No definition found.' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(attachableFromChunk(chunk)).toEqual([
|
||||||
|
{ key: 'x:John 1:1', type: 'xref', label: 'John 1:1' },
|
||||||
|
{ key: 'w:G1401', type: 'word', label: 'δοῦλος (G1401)', detail: 'a slave' },
|
||||||
|
{ key: 'w:xyz', type: 'word', label: 'xyz', detail: undefined },
|
||||||
|
]);
|
||||||
|
expect(attachableFromChunk(null)).toEqual([]);
|
||||||
|
});
|
||||||
@@ -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