Compare commits

...
13 Commits
Author SHA1 Message Date
nmemmertandClaude Sonnet 5.5 46a6e3bf7a Redesign the login page around what the app can do
- Hero ("Read it. Study it. Teach it.") with small previews of the outline
  and the translation comparison
- Four groups of features (Read, Study, Teach, Everywhere), all things the
  app really does
- Sign-in / create-account card stays in view beside the tour on wide
  screens and comes first on a phone; theme switcher before signing in
- Sign-in, account creation, 2FA and error handling unchanged

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:18:50 -04:00
nmemmertandClaude Sonnet 5.5 49a0eb9d0b Merge outline-style: restyled outline share page, print/PDF and Word
Build and Push Docker Image / build-and-push (push) Failing after 20s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:12:41 -04:00
nmemmertandClaude Sonnet 5.5 57db2310dd Outline: restyle the share page and the printed / Word outline
- Read-only share page: a calm paper-toned page rendered natively (no
  iframe), with outline tabs, Print / PDF, numbered points with accent
  numerals, type tags, notes and verse quotes; works on phones
- Printable / PDF page restyled to match (serif, hanging numerals, tidy
  spacing, footer)
- Word outline restyled: eyebrow + title block, bold main points, accent
  numerals, set-off verse quotes, tidy margins

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:10:42 -04:00
nmemmertandClaude Sonnet 5.5 34d04fd077 Merge outline-share-link: read-only outline links
Build and Push Docker Image / build-and-push (push) Successful in 4m33s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:02:41 -04:00
nmemmertandClaude Sonnet 5.5 d7a1601f80 Outline: share as a read-only link
- Share on the Outline page opens a sheet: create / copy / turn off a
  read-only link, share the link with the system share sheet, or send the
  outline as text
- The link has its own token (separate from the whole-study share link) and
  the public endpoint returns only the outlines, rebuilt field by field:
  no observations, interpretations, applications, scripts or word notes
- Opening the link shows the outline(s) with no sign-in, a switcher between
  the whole-study and per-chunk outlines, and Print / PDF
- New outline_share_token column (added automatically on start)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:02:40 -04:00
nmemmertandClaude Sonnet 5.5 2ca79730f1 Merge reader-compare: translation comparison with a button next to Aa
Build and Push Docker Image / build-and-push (push) Successful in 4m9s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:40:28 -04:00
nmemmertandClaude Sonnet 5.5 5ab416930b Reader: compare button next to Aa
A button beside Aa in the reader's top bar opens a Compare translations
sheet (pick the Bible to compare with, show/hide the differing-word
highlights). It shows as active while a comparison is on. The controls
moved out of the Aa sheet; "C" opens it from the keyboard.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:40:24 -04:00
nmemmertandClaude Sonnet 5.5 aa27a5bb18 Reader: show how much another translation differs from the one being read
- Word-level comparison (ignores case and punctuation): differing words are
  marked in both columns of the side-by-side view
- Chapter summary ("NET is 18% different from BSB · 37 of 39 verses differ")
  with links to the most different verses, and a percentage on each verse
- Compare tab on a verse shows each translation's difference from the one
  being read, with the differing words marked
- "Highlight differences" setting; your own highlights and search matches
  always win where they overlap a difference mark

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:36:46 -04:00
nmemmertandClaude Sonnet 5.5 7101be393e Merge outline-richer: per-chunk outlines, richer points, print and share
Build and Push Docker Image / build-and-push (push) Failing after 20s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:24:49 -04:00
nmemmertandClaude Sonnet 5.5 820886e3ab Outline: per-chunk outlines, richer points, print and share
- One outline for the whole study plus one per chunk (picker at the top);
  chunk outlines can start from the chunk's verses, each carrying its words
- Points can have a type (illustration, application, quote, question,
  transition), minutes (with a running total), a longer note, verse text
  fetched from a passage, and cross-references / word studies attached from
  the chunk
- Share (system share sheet, or copy), Print / PDF, Copy as text, Word and
  Markdown downloads for any one outline
- All of it included in the Markdown, HTML/PDF and Word study exports,
  with each chunk's outline under that chunk

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:24:28 -04:00
nmemmertandClaude Sonnet 5.5 d273c8cb3e Merge ui-features-flow: Outline stage, parallel reading, offline, backup, sync conflicts
Build and Push Docker Image / build-and-push (push) Failing after 21s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:09:07 -04:00
nmemmertandClaude Sonnet 5.5 b4f038b185 Show the current page in the browser tab title
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:09:06 -04:00
nmemmertandClaude Sonnet 5.5 d13f55faf7 Add Outline stage, parallel reading, offline saves, backup, sync-conflict handling and more
Study
- New Outline stage (hierarchical I./A./1. points, linked to chunks, in all exports);
  the chunk stage is now "Chunks"
- Home cards: passages shown as ranges, tags capped with "+N more"

Reader
- Side-by-side translations and a Compare tab per verse
- Typeface, line spacing, verse-number settings; text and column scale with the screen
- Highlights/bookmarks sheet with named colours (synced); BSB footnote spacing fix
- Cmd-K palette (passages, studies, notes, marks), keyboard shortcuts and "?" help

Flow and safety
- Toasts with Undo replace most confirm()/alert()
- Back/Forward move between pages
- Server refuses saves over a newer copy (409); keep-mine / use-other banner;
  opening a study no longer re-stamps it
- Latest chapter request wins when flipping quickly

Offline and data
- Save books, plan days or a whole BSB/NET for offline
- Service worker fixes: helloao /api/ URLs were skipped and responses cloned too late
- Settings: download/restore a full backup

Accessibility
- Sheets trap focus and close on Escape, skip link, focus ring, reduced motion,
  theme contrast raised to 4.5:1 (with a test)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:08:08 -04:00
84 changed files with 4681 additions and 250 deletions

No files matched your search

+18 -6
View File
@@ -1,4 +1,4 @@
const SHELL = 'bible-shell-v1'; const SHELL = 'bible-shell-v2';
const API = 'bible-api-v1'; const API = 'bible-api-v1';
// Chapter text, interlinear cross-refs, and (BSB) chapter audio all come from // Chapter text, interlinear cross-refs, and (BSB) chapter audio all come from
// these origins — cache them so the reader, reading plans, and audio all keep // these origins — cache them so the reader, reading plans, and audio all keep
@@ -27,8 +27,10 @@ self.addEventListener('fetch', (e) => {
const { request } = e; const { request } = e;
const url = request.url; const url = request.url;
// Never intercept API routes — let Express handle them // Never intercept this app's own API routes — let Express handle them. (Only same-origin ones: the
if (new URL(url).pathname.startsWith('/api/')) return; // Bible text at bible.helloao.org/api/... must pass through to the offline cache below.)
const { origin, pathname } = new URL(url);
if (origin === self.location.origin && pathname.startsWith('/api/')) return;
// External Bible API (text, cross-refs, audio): network-first, fall back to cache. // External Bible API (text, cross-refs, audio): network-first, fall back to cache.
// Audio elements often issue Range requests, which come back as 206 Partial // Audio elements often issue Range requests, which come back as 206 Partial
@@ -37,7 +39,11 @@ self.addEventListener('fetch', (e) => {
e.respondWith( e.respondWith(
fetch(request) fetch(request)
.then(res => { .then(res => {
if (res.status === 200) caches.open(API).then(c => c.put(request, res.clone())); // Clone *now*: once the page starts reading the body a later clone() throws and nothing is saved.
if (res.status === 200) {
const copy = res.clone();
e.waitUntil(caches.open(API).then(c => c.put(request, copy)).catch(() => {}));
}
return res; return res;
}) })
.catch(() => .catch(() =>
@@ -56,7 +62,10 @@ self.addEventListener('fetch', (e) => {
e.respondWith( e.respondWith(
fetch(request) fetch(request)
.then(res => { .then(res => {
if (res.ok) caches.open(SHELL).then(c => c.put(request, res.clone())); if (res.ok) {
const copy = res.clone();
e.waitUntil(caches.open(SHELL).then(c => c.put(request, copy)).catch(() => {}));
}
return res; return res;
}) })
.catch(() => .catch(() =>
@@ -75,7 +84,10 @@ self.addEventListener('fetch', (e) => {
c.match(request).then(hit => { c.match(request).then(hit => {
if (hit) return hit; if (hit) return hit;
return fetch(request).then(res => { return fetch(request).then(res => {
if (res.ok) c.put(request, res.clone()); if (res.ok) {
const copy = res.clone();
e.waitUntil(c.put(request, copy).catch(() => {}));
}
return res; return res;
}); });
}) })
+32
View File
@@ -83,6 +83,10 @@ export function initDb() {
if (!projectCols.some((c) => c.name === 'share_token')) { if (!projectCols.some((c) => c.name === 'share_token')) {
db.exec('ALTER TABLE projects ADD COLUMN share_token TEXT'); db.exec('ALTER TABLE projects ADD COLUMN share_token TEXT');
} }
if (!projectCols.some((c) => c.name === 'outline_share_token')) {
db.exec('ALTER TABLE projects ADD COLUMN outline_share_token TEXT');
}
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_outline_share_token ON projects(outline_share_token) WHERE outline_share_token IS NOT NULL');
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_share_token ON projects(share_token) WHERE share_token IS NOT NULL'); db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_share_token ON projects(share_token) WHERE share_token IS NOT NULL');
// Reader ink — one row per user + book + chapter, stored as JSON // Reader ink — one row per user + book + chapter, stored as JSON
@@ -353,6 +357,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.
@@ -408,6 +418,28 @@ export function clearShareToken(id, userId) {
db.prepare('UPDATE projects SET share_token = NULL WHERE id = ? AND user_id = ?').run(id, userId); db.prepare('UPDATE projects SET share_token = NULL WHERE id = ? AND user_id = ?').run(id, userId);
} }
// ---- read-only outline links: a separate token, so sharing an outline never shares the whole study ----
export function getOutlineShareToken(id, userId) {
const row = db.prepare('SELECT outline_share_token AS t FROM projects WHERE id = ? AND user_id = ?').get(id, userId);
return row?.t ?? null;
}
export function setOutlineShareToken(id, userId, token) {
return db.prepare('UPDATE projects SET outline_share_token = ? WHERE id = ? AND user_id = ?').run(token, id, userId).changes > 0;
}
export function clearOutlineShareToken(id, userId) {
db.prepare('UPDATE projects SET outline_share_token = NULL WHERE id = ? AND user_id = ?').run(id, userId);
}
/** Public lookup: the project data behind an outline link (callers must only ever send the outlines on). */
export function getProjectByOutlineToken(token) {
const row = db.prepare('SELECT data FROM projects WHERE outline_share_token = ?').get(token);
if (!row) return null;
try { return JSON.parse(row.data); } catch { return null; }
}
/** Public lookup: returns the full project data for a valid share token, or null. No ownership check — this is the point. */ /** Public lookup: returns the full project data for a valid share token, or null. No ownership check — this is the point. */
export function getProjectByShareToken(token) { export function getProjectByShareToken(token) {
const row = db.prepare('SELECT data FROM projects WHERE share_token = ?').get(token); const row = db.prepare('SELECT data FROM projects WHERE share_token = ?').get(token);
+53 -1
View File
@@ -6,10 +6,11 @@ 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,
getOutlineShareToken, setOutlineShareToken, clearOutlineShareToken, getProjectByOutlineToken,
adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject, adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject,
setUserPassword, destroyAllSessionsForUser, setUserPassword, destroyAllSessionsForUser,
getAllReaderInk, setReaderInkPage, getReaderMarks, setReaderMarks, getUserPrefs, setUserPrefs, getAllReaderInk, setReaderInkPage, getReaderMarks, setReaderMarks, getUserPrefs, setUserPrefs,
@@ -26,6 +27,8 @@ 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';
import { buildOutlinePayload } from './outlineShare.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 +401,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.' });
@@ -593,6 +601,50 @@ app.delete('/api/projects/:id/share', requireAuth, (req, res) => {
} }
}); });
// Read-only outline links. A separate token from the study share link, and the public endpoint returns
// only the outlines (see outlineShare.js), never the notes or scripts.
app.get('/api/projects/:id/outline-share', requireAuth, (req, res) => {
try {
res.json({ shareToken: getOutlineShareToken(req.params.id, req.session.userId) });
} catch (err) {
console.error('GET /api/projects/:id/outline-share error:', err);
res.status(500).json({ error: 'Failed to load share status.' });
}
});
app.post('/api/projects/:id/outline-share', requireAuth, (req, res) => {
try {
const token = getOutlineShareToken(req.params.id, req.session.userId) || randomUUID().replace(/-/g, '');
if (!setOutlineShareToken(req.params.id, req.session.userId, token)) return res.status(404).json({ error: 'Project not found.' });
res.json({ shareToken: token });
} catch (err) {
console.error('POST /api/projects/:id/outline-share error:', err);
res.status(500).json({ error: 'Failed to enable sharing.' });
}
});
app.delete('/api/projects/:id/outline-share', requireAuth, (req, res) => {
try {
clearOutlineShareToken(req.params.id, req.session.userId);
res.json({ ok: true });
} catch (err) {
console.error('DELETE /api/projects/:id/outline-share error:', err);
res.status(500).json({ error: 'Failed to revoke sharing.' });
}
});
// GET /api/share-outline/:token — PUBLIC, no login required
app.get('/api/share-outline/:token', (req, res) => {
try {
const project = getProjectByOutlineToken(req.params.token);
if (!project) return res.status(404).json({ error: 'This outline link is invalid or has been turned off.' });
res.json(buildOutlinePayload(project));
} catch (err) {
console.error('GET /api/share-outline/:token error:', err);
res.status(500).json({ error: 'Failed to load the shared outline.' });
}
});
// GET /api/share/:token — PUBLIC, no login required: fetch a shared project read-only // GET /api/share/:token — PUBLIC, no login required: fetch a shared project read-only
app.get('/api/share/:token', (req, res) => { app.get('/api/share/:token', (req, res) => {
try { try {
+59
View File
@@ -0,0 +1,59 @@
// What a read-only outline link shows: the outlines of a study (the whole-study one and each chunk's)
// and nothing else — no observations, interpretations, applications, scripts or word notes. The
// outline data comes from the browser, so it is rebuilt field by field instead of passed through.
const MAX_NODES = 2000;
const MAX_DEPTH = 8;
const KINDS = new Set(['', 'illustration', 'application', 'quote', 'question', 'transition']);
const str = (v, max) => (typeof v === 'string' ? v.slice(0, max) : '');
function cleanTree(nodes, depth, counter) {
if (!Array.isArray(nodes) || depth > MAX_DEPTH) return [];
const out = [];
for (const n of nodes) {
if (!n || typeof n !== 'object' || counter.n >= MAX_NODES) continue;
counter.n += 1;
const minutes = Number.isFinite(n.minutes) && n.minutes >= 0 && n.minutes <= 600 ? n.minutes : null;
out.push({
id: str(n.id, 64) || `n${counter.n}`,
text: str(n.text, 2000),
ref: str(n.ref, 200),
kind: KINDS.has(n.kind) ? n.kind : '',
minutes,
note: str(n.note, 8000),
scripture: n.scripture && typeof n.scripture.text === 'string'
? { ref: str(n.scripture.ref, 200), text: str(n.scripture.text, 6000), translation: str(n.scripture.translation, 12) }
: null,
attachments: (Array.isArray(n.attachments) ? n.attachments : []).slice(0, 50)
.filter((a) => a && typeof a === 'object')
.map((a) => ({ id: str(a.id, 100), type: a.type === 'word' ? 'word' : 'xref', label: str(a.label, 200), ...(a.detail ? { detail: str(a.detail, 500) } : {}) })),
children: cleanTree(n.children, depth + 1, counter),
});
}
return out;
}
const chunkLabel = (chapters, chapterIndex, chunk) => {
const ch = chapters[chapterIndex];
const next = chapters[chapterIndex + 1];
if (Number.isInteger(chunk.spilloverEndVerse) && next && next.bookAbbrev === ch.bookAbbrev) {
return `${ch.book} ${ch.chapter}:${chunk.startVerse}–${next.chapter}:${chunk.spilloverEndVerse}`;
}
return chunk.startVerse === chunk.endVerse ? `${ch.book} ${ch.chapter}:${chunk.startVerse}` : `${ch.book} ${ch.chapter}:${chunk.startVerse}–${chunk.endVerse}`;
};
// -> { title, translation, outlines: [{ scope, label, tree }] } with only the outlines that have points
export function buildOutlinePayload(project) {
const counter = { n: 0 };
const outlines = [];
const whole = cleanTree(project?.outline, 0, counter);
if (whole.length > 0) outlines.push({ scope: 'study', label: 'Whole study', tree: whole });
const chapters = Array.isArray(project?.chapters) ? project.chapters : [];
chapters.forEach((ch, chapterIndex) => {
for (const chunk of Array.isArray(ch?.chunks) ? ch.chunks : []) {
const tree = cleanTree(chunk?.outline, 0, counter);
if (tree.length > 0) outlines.push({ scope: str(chunk.id, 64), label: str(chunkLabel(chapters, chapterIndex, chunk), 200), tree });
}
});
return { title: str(project?.title, 500), translation: str(project?.translation, 12) || 'BSB', outlines };
}
+42
View File
@@ -0,0 +1,42 @@
import { expect, test } from 'vitest';
import { buildOutlinePayload } from './outlineShare.js';
const node = (id, extra = {}) => ({ id, text: id, ref: '', kind: '', minutes: null, note: '', scripture: null, attachments: [], children: [], ...extra });
const project = {
title: 'Titus', translation: 'BSB',
outline: [node('a', { minutes: 5, kind: 'illustration', children: [node('a1')] })],
chapters: [{
book: 'Titus', bookAbbrev: 'TIT', chapter: '1', verses: [{ number: 1, text: 'secret verse text' }],
chunks: [
{ id: 'c1', startVerse: 1, endVerse: 4, observation: 'PRIVATE OBSERVATION', finalScript: 'PRIVATE SCRIPT', greekWords: [{ lexeme: 'x' }], outline: [node('b')] },
{ id: 'c2', startVerse: 5, endVerse: 5, observation: 'more private', outline: [] },
],
}],
};
test('shares the outlines only, with each chunk labelled by its passage', () => {
const p = buildOutlinePayload(project);
expect(p.title).toBe('Titus');
expect(p.outlines.map((o) => [o.scope, o.label])).toEqual([['study', 'Whole study'], ['c1', 'Titus 1:1–4']]);
expect(p.outlines[0].tree[0]).toMatchObject({ text: 'a', minutes: 5, kind: 'illustration' });
expect(p.outlines[0].tree[0].children[0].text).toBe('a1');
});
test('nothing from the rest of the study leaks into what is shared', () => {
const json = JSON.stringify(buildOutlinePayload(project));
for (const secret of ['PRIVATE OBSERVATION', 'PRIVATE SCRIPT', 'secret verse text', 'more private', 'greekWords']) expect(json).not.toContain(secret);
});
test('unknown fields on a point are dropped, and bad values are cleaned', () => {
const p = buildOutlinePayload({ title: 'T', outline: [{ id: 'x', text: 'hi', evil: '<script>', kind: 'bogus', minutes: -4, scripture: { text: 5 }, attachments: [{ type: 'word', label: 'L' }, null], children: 'nope' }], chapters: [] });
expect(p.outlines[0].tree[0]).toEqual({
id: 'x', text: 'hi', ref: '', kind: '', minutes: null, note: '', scripture: null,
attachments: [{ id: '', type: 'word', label: 'L' }], children: [],
});
});
test('a study with no outlines shares none, and a huge outline is capped', () => {
expect(buildOutlinePayload({ title: 'T', chapters: [] }).outlines).toEqual([]);
const big = { title: 'T', outline: Array.from({ length: 3000 }, (_, i) => node(`n${i}`)), chapters: [] };
expect(buildOutlinePayload(big).outlines[0].tree).toHaveLength(2000);
});
+10
View File
@@ -10,10 +10,20 @@ const ENUMS = {
commentarySource: ['matthew-henry', 'john-gill', 'jamieson-fausset-brown', 'tyndale', 'adam-clarke', 'john-calvin', 'keil-delitzsch'], commentarySource: ['matthew-henry', 'john-gill', 'jamieson-fausset-brown', 'tyndale', 'adam-clarke', 'john-calvin', 'keil-delitzsch'],
}; };
const DAY_MS = 86_400_000; const DAY_MS = 86_400_000;
const COLOR = /^#[0-9a-fA-F]{3,8}$/;
// A cleaned value, or undefined when it isn't acceptable for that preference. // A cleaned value, or undefined when it isn't acceptable for that preference.
export function cleanPrefValue(key, value) { export function cleanPrefValue(key, value) {
if (ENUMS[key]) return ENUMS[key].includes(value) ? value : undefined; if (ENUMS[key]) return ENUMS[key].includes(value) ? value : undefined;
if (key === 'colorLabels') {
// What each highlight colour means to the reader: { "#fef08a": "Promises", ... }
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
const out = {};
for (const [color, label] of Object.entries(value).slice(0, 12)) {
if (COLOR.test(color) && typeof label === 'string' && label.trim()) out[color] = label.trim().slice(0, 30);
}
return out;
}
if (key === 'position') { if (key === 'position') {
if (!value || typeof value !== 'object') return undefined; if (!value || typeof value !== 'object') return undefined;
const book = String(value.book ?? '').toUpperCase(); const book = String(value.book ?? '').toUpperCase();
+18
View File
@@ -0,0 +1,18 @@
// Stops one device from silently overwriting a study another device has saved since. The browser sends
// the `lastEdited` of the server copy it last saw (X-Base-Edited); if the server's copy has moved past
// that, the save is refused with 409 and the person chooses whose version to keep.
// stored: the server copy's lastEdited (null when there is none); base: what the client last saw
// (null/undefined when it never synced this study); force: the person chose "keep mine".
export function isSaveConflict({ stored, base, force = false }) {
if (force || stored == null) return false;
if (!Number.isFinite(base)) return false; // a device that never saw the server's copy has nothing to compare
return stored > base;
}
// Reads the base header; anything that isn't a plain number is treated as "unknown".
export function parseBase(header) {
if (header == null || header === '') return null;
const n = Number(header);
return Number.isFinite(n) ? n : null;
}
+24
View File
@@ -0,0 +1,24 @@
import { expect, test } from 'vitest';
import { isSaveConflict, parseBase } from './projectSync.js';
test('a save is fine when the server copy is no newer than what the device last saw', () => {
expect(isSaveConflict({ stored: 100, base: 100 })).toBe(false);
expect(isSaveConflict({ stored: 90, base: 100 })).toBe(false);
});
test('a save conflicts when another device saved after the one this device last saw', () => {
expect(isSaveConflict({ stored: 200, base: 100 })).toBe(true);
});
test('first saves, unknown bases and "keep mine" never conflict', () => {
expect(isSaveConflict({ stored: null, base: 100 })).toBe(false);
expect(isSaveConflict({ stored: 200, base: null })).toBe(false);
expect(isSaveConflict({ stored: 200, base: 100, force: true })).toBe(false);
});
test('reads the base header', () => {
expect(parseBase('1700000000000')).toBe(1700000000000);
expect(parseBase('')).toBeNull();
expect(parseBase(undefined)).toBeNull();
expect(parseBase('abc')).toBeNull();
});
+5 -2
View File
@@ -30,10 +30,13 @@ export function cleanMarks(body) {
if (!int(h.startOffset, 0, 5000) || !int(h.endOffset, 1, 5000) || h.endOffset <= h.startOffset) continue; if (!int(h.startOffset, 0, 5000) || !int(h.endOffset, 1, 5000) || h.endOffset <= h.startOffset) continue;
if (typeof h.color !== 'string' || !COLOR.test(h.color)) continue; if (typeof h.color !== 'string' || !COLOR.test(h.color)) continue;
seen.add(h.id); seen.add(h.id);
highlights.push({ const entry = {
id: h.id, book: String(h.book).toUpperCase(), chapter: h.chapter, verse: h.verse, id: h.id, book: String(h.book).toUpperCase(), chapter: h.chapter, verse: h.verse,
startOffset: h.startOffset, endOffset: h.endOffset, color: h.color, startOffset: h.startOffset, endOffset: h.endOffset, color: h.color,
}); };
// The highlighted words, kept so lists of highlights can show them without loading the chapter
if (typeof h.text === 'string' && h.text.length > 0) entry.text = h.text.slice(0, 500);
highlights.push(entry);
} }
} }
return { bookmarks, highlights }; return { bookmarks, highlights };
+443
View File
@@ -0,0 +1,443 @@
// 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, outlineToken: null };
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.endsWith('/outline-share')) {
if (opts.method === 'POST') state.outlineToken = 'abc123';
if (opts.method === 'DELETE') state.outlineToken = null;
return json(200, opts.method === 'DELETE' ? { ok: true } : { shareToken: state.outlineToken ?? null });
}
if (route === 'GET /api/share-outline/abc123') {
return json(200, { title: 'Titus', translation: 'BSB', outlines: [
{ scope: 'study', label: 'Whole study', tree: [{ id: 'a', text: 'Appoint elders', ref: '', kind: '', minutes: null, note: '', scripture: null, attachments: [], children: [] }] },
{ scope: 'c1', label: 'Titus 1:1–4', tree: [{ id: 'b', text: 'Greeting', ref: '', kind: '', minutes: null, note: '', scripture: null, attachments: [], children: [] }] },
] });
}
if (route === 'GET /api/share-outline/nope') return json(404, { error: 'This outline link is invalid or has been turned off.' });
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 opens a sheet; with no account it offers the text, which is copied', 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 }));
const sheet = await screen.findByRole('dialog', { name: /share this outline/i });
expect(await within(sheet).findByText(/links need your account/i)).toBeInTheDocument();
await user.click(within(sheet).getByRole('button', { name: /share as text/i }));
expect(await screen.findByText(/outline copied/i)).toBeInTheDocument();
});
test('Print opens a page of just the outline', async () => {
const user = await openOutline();
await user.click(screen.getByRole('button', { name: /start from my chunks/i }));
await screen.findByRole('list', { name: 'Outline' });
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('Read-only outline link', () => {
const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: '', isAdmin: false };
test('create the link, see it, and turn it off', async () => {
const server = stubServer({ user });
const u = userEvent.setup();
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);
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 u.click(screen.getByRole('button', { name: /^outline$/i }));
await u.click(await screen.findByRole('button', { name: /start from my chunks/i }));
await u.click(await screen.findByRole('button', { name: /^share$/i }));
const sheet = await screen.findByRole('dialog', { name: /share this outline/i });
await u.click(await within(sheet).findByRole('button', { name: /create read-only link/i }));
const box = await within(sheet).findByLabelText('Outline link');
expect(box.value).toMatch(/\?outline=abc123$/);
expect(server.calls.some((c) => c.route === 'POST /api/projects/' + c.url.split('/')[3] + '/outline-share')).toBe(true);
await u.click(within(sheet).getByRole('button', { name: /turn off link/i }));
expect(await within(sheet).findByRole('button', { name: /create read-only link/i })).toBeInTheDocument();
}, 15000);
});
describe('Opening a read-only outline link', () => {
afterEach(() => window.history.replaceState({}, '', '/'));
test('shows the outline with no sign-in and a way to switch between outlines', async () => {
stubServer({ user: null });
window.history.replaceState({}, '', '/?outline=abc123');
render(<App />);
expect(await screen.findByRole('heading', { name: 'Titus' })).toBeInTheDocument();
expect(screen.getByText('Appoint elders')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /sign in/i })).not.toBeInTheDocument();
expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); // nothing to edit
await userEvent.setup().click(screen.getByRole('button', { name: 'Titus 1:1–4' }));
expect(await screen.findByRole('heading', { name: 'Titus — Titus 1:1–4' })).toBeInTheDocument();
expect(screen.getByText('Greeting')).toBeInTheDocument();
expect(screen.queryByText('Appoint elders')).not.toBeInTheDocument();
});
test('a turned-off or wrong link says so', async () => {
stubServer({ user: null });
window.history.replaceState({}, '', '/?outline=nope');
render(<App />);
expect(await screen.findByText(/invalid or has been turned off/i)).toBeInTheDocument();
});
});
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$/));
});
});
describe('Login page', () => {
async function openLogin() {
stubServer({ user: null });
render(<App />);
return screen.findByRole('button', { name: /^sign in$/i });
}
test('tells you what the app can do, in groups', async () => {
await openLogin();
expect(screen.getByRole('heading', { level: 1, name: /read it\. study it\./i })).toBeInTheDocument();
for (const group of ['Read', 'Study', 'Teach', 'Everywhere']) expect(screen.getByRole('region', { name: group })).toBeInTheDocument();
expect(screen.getByText('A real outline')).toBeInTheDocument();
expect(screen.getByText('Compare them')).toBeInTheDocument();
});
test('switches between signing in and creating an account', async () => {
const user = userEvent.setup();
await openLogin();
expect(screen.getByRole('heading', { name: 'Sign in' })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /need an account/i }));
expect(screen.getByRole('heading', { name: 'Create an account' })).toBeInTheDocument();
expect(screen.getByText(/at least 8 characters/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^create account$/i })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /already have an account/i }));
expect(screen.getByRole('heading', { name: 'Sign in' })).toBeInTheDocument();
});
test('a theme can be picked before signing in', async () => {
const user = userEvent.setup();
await openLogin();
await user.click(screen.getByRole('button', { name: 'Dark' }));
expect(screen.getByRole('button', { name: 'Dark' })).toHaveAttribute('aria-pressed', 'true');
expect(localStorage.getItem('reader-theme')).toBe('dark');
});
test('a wrong password shows the error', async () => {
const user = userEvent.setup();
vi.stubGlobal('fetch', vi.fn((url, opts = {}) => {
if (url === '/api/auth/me') return json(401, {});
if (url === '/api/auth/login') return json(401, { error: 'Incorrect email or password.' });
return Promise.resolve({ ok: false });
}));
render(<App />);
await user.type(await screen.findByLabelText(/email/i), 'a@b.co');
await user.type(screen.getByLabelText(/^password/i), 'wrong-password');
await user.click(screen.getByRole('button', { name: /^sign in$/i }));
expect(await screen.findByRole('alert')).toHaveTextContent(/incorrect email or password/i);
});
});
+156 -27
View File
@@ -2,18 +2,25 @@ 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 SharedOutlineView from './components/SharedOutlineView.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 +31,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 +50,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 +78,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 +111,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') && !new URLSearchParams(window.location.search).has('outline'),
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 +163,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 +211,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 +336,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 +372,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 +394,7 @@ const App = () => {
saveStatus, setSaveStatus, saveStatus, setSaveStatus,
syncStatus, syncStatus,
retrySync, retrySync,
syncConflict, keepMyVersion, useServerVersion,
// Home // Home
projectIndex, projectIndex,
...library, ...library,
@@ -303,6 +407,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 +435,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>
); );
} }
@@ -339,6 +462,10 @@ function PageLoading() {
function AppRouter() { function AppRouter() {
const { currentPage, authUser, authServerDown } = useApp(); const { currentPage, authUser, authServerDown } = useApp();
const { token: sharedViewToken, project: sharedProject, error: sharedError } = useSharedView(); const { token: sharedViewToken, project: sharedProject, error: sharedError } = useSharedView();
const sharedOutlineToken = new URLSearchParams(window.location.search).get('outline');
// A read-only outline link: no sign-in, nothing editable
if (sharedOutlineToken) return <SharedOutlineView token={sharedOutlineToken} />;
if (sharedViewToken) { if (sharedViewToken) {
if (sharedError) { if (sharedError) {
@@ -374,13 +501,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 +511,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 +519,11 @@ function AppRouter() {
return <HomePage />; return <HomePage />;
} }
// The toast provider sits outside so every page (and App itself) can show toasts.
const App = () => (
<ToastProvider>
<AppInner />
</ToastProvider>
);
export default App; export default App;
+12 -9
View File
@@ -588,14 +588,17 @@ describe('Home page', () => {
expect(JSON.parse(localStorage.getItem('bible-study-index'))[0].title).toBe('Titus Deep Dive'); expect(JSON.parse(localStorage.getItem('bible-study-index'))[0].title).toBe('Titus Deep Dive');
}); });
test('deleting a project removes it after confirmation', async () => { test('deleting a project removes it at once, and Undo brings it back', async () => {
seedProject(); seedProject();
window.confirm.mockReturnValue(true);
const user = userEvent.setup(); const user = userEvent.setup();
await renderApp(); await renderApp();
await user.click(screen.getByRole('button', { name: /^delete$/i })); await user.click(screen.getByRole('button', { name: /^delete$/i }));
await waitFor(() => expect(screen.queryByText('Titus Notes')).not.toBeInTheDocument()); await waitFor(() => expect(screen.queryByText('Titus Notes')).not.toBeInTheDocument());
expect(localStorage.getItem('bible-study-project-p1')).toBeNull(); expect(localStorage.getItem('bible-study-project-p1')).toBeNull();
expect(window.confirm).not.toHaveBeenCalled();
await user.click(await screen.findByRole('button', { name: /undo/i }));
expect(await screen.findByText('Titus Notes')).toBeInTheDocument();
expect(localStorage.getItem('bible-study-project-p1')).not.toBeNull();
}); });
test('sort choice is remembered after leaving Home', async () => { test('sort choice is remembered after leaving Home', async () => {
@@ -760,28 +763,28 @@ describe('Chunk builder', () => {
expect(await addSecondChapter(user)).toHaveAttribute('aria-current', 'true'); expect(await addSecondChapter(user)).toHaveAttribute('aria-current', 'true');
}); });
test('the Outline stage reopens the chapter of the chunk being studied', async () => { test('the Chunks stage reopens the chapter of the chunk being studied', async () => {
const user = await loadChapter(); const user = await loadChapter();
await addSecondChapter(user); await addSecondChapter(user);
addChunk('Paul, a servant', 'In hope of eternal'); // chunk in Titus 2 (now selected) addChunk('Paul, a servant', 'In hope of eternal'); // chunk in Titus 2 (now selected)
await user.click(screen.getByRole('button', { name: 'Titus 1' })); await user.click(screen.getByRole('button', { name: 'Titus 1' }));
await user.click(screen.getByRole('button', { name: /begin studying/i })); await user.click(screen.getByRole('button', { name: /begin studying/i }));
await findChunkCounter(1, 1); await findChunkCounter(1, 1);
await user.click(screen.getByRole('button', { name: /^outline$/i })); await user.click(screen.getByRole('button', { name: /^chunks$/i }));
expect(await screen.findByRole('button', { name: 'Titus 2' })).toHaveAttribute('aria-current', 'true'); expect(await screen.findByRole('button', { name: 'Titus 2' })).toHaveAttribute('aria-current', 'true');
}); });
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Stage bar (Outline / Study / Script / Export) // Stage bar (Chunks / Study / Outline / Script / Export)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe('Stage bar', () => { describe('Stage bar', () => {
test('later stages are disabled until a chunk exists', async () => { test('later stages are disabled until a chunk exists', async () => {
await loadChapter(); await loadChapter();
for (const name of [/^study$/i, /^script$/i, /^export$/i]) { for (const name of [/^study$/i, /^outline$/i, /^script$/i, /^export$/i]) {
expect(screen.getByRole('button', { name })).toBeDisabled(); expect(screen.getByRole('button', { name })).toBeDisabled();
} }
expect(screen.getByRole('button', { name: /^outline$/i })).not.toBeDisabled(); expect(screen.getByRole('button', { name: /^chunks$/i })).not.toBeDisabled();
addChunk('Paul, a servant', 'Paul, a servant'); addChunk('Paul, a servant', 'Paul, a servant');
await screen.findByText('1 created'); await screen.findByText('1 created');
expect(screen.getByRole('button', { name: /^study$/i })).not.toBeDisabled(); expect(screen.getByRole('button', { name: /^study$/i })).not.toBeDisabled();
@@ -1240,7 +1243,7 @@ describe('Back to the dashboard', () => {
await dashboard(); await dashboard();
}); });
test.each(['Outline', 'Study', 'Script', 'Export'])('the %s stage of a study', async (stage) => { test.each(['Chunks', 'Study', 'Outline', 'Script', 'Export'])('the %s stage of a study', async (stage) => {
const u = await loadChapter(); const u = await loadChapter();
addChunk('Paul, a servant', 'In hope of eternal'); addChunk('Paul, a servant', 'In hope of eternal');
await screen.findByText('1 created'); await screen.findByText('1 created');
@@ -1431,7 +1434,7 @@ describe('New pages start at the top', () => {
await screen.findByRole('heading', { name: /^reading plans$/i }); await screen.findByRole('heading', { name: /^reading plans$/i });
expect(scroller.scrollTop).toBe(0); expect(scroller.scrollTop).toBe(0);
scroller.scrollTop = 300; scroller.scrollTop = 300;
await user.click(screen.getByRole('button', { name: /back to home/i })); await user.click(screen.getByRole('button', { name: /back to home|bible study home/i }));
await screen.findByText('My Studies'); await screen.findByText('My Studies');
expect(scroller.scrollTop).toBe(0); expect(scroller.scrollTop).toBe(0);
}); });
+132
View File
@@ -0,0 +1,132 @@
// Press ⌘K (Ctrl+K) anywhere: type a passage ("john 3:16") to go straight to it, or a few letters to
// find a study, a note you wrote, a bookmark or highlight, a page, or to search the Bible's text.
import { useEffect, useMemo, useRef, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { themeVars } from './reader/readerThemes.js';
import { SearchIcon } from './reader/icons.jsx';
import { formatPassage, parsePassageRef } from '../utils/passageRef.js';
import { searchMarks, searchNotes, searchProjects } from '../utils/personalSearch.js';
import { loadProjectIndex } from '../utils/bibleUtils.jsx';
import { loadProjectById } from '../utils/projectStorage.js';
export default function CommandPalette({ onClose }) {
const {
readerTheme, authUser, authServerDown, projectIndex, readerBookmarks, readerTextHighlights, readerColorLabels,
jumpToReaderVerse, setCurrentPage, setProject, resumeProject, openNewProject, goStudies, openReadingPlans,
openBibleReader, requestReaderSearch, openBackup,
} = useApp();
const [query, setQuery] = useState('');
const [active, setActive] = useState(0);
const listRef = useRef(null);
const inputRef = useRef(null);
const opener = useRef(document.activeElement);
useEffect(() => {
inputRef.current?.focus();
const from = opener.current;
return () => { if (from instanceof HTMLElement && document.contains(from)) from.focus({ preventScroll: true }); };
}, []);
const items = useMemo(() => {
const out = [];
const q = query.trim();
const go = (fn) => () => { onClose(); fn(); };
// 1. A passage typed in
for (const r of parsePassageRef(q)) {
out.push({ group: 'Go to', label: formatPassage(r), hint: 'Open in the Bible', run: go(() => { jumpToReaderVerse(r.book, r.chapter, r.verse ?? 1); openBibleReader(); }) });
}
// 2. Pages and actions
const pages = [
['Studies', 'My Studies', goStudies], ['Read Bible', 'The Bible reader', openBibleReader], ['Reading Plans', 'Plans and today’s reading', openReadingPlans],
['New project', 'Start a Bible study', openNewProject], ['Settings', 'Account and preferences', () => setCurrentPage('settings')],
['Back up my data', 'Download everything as a file', openBackup],
];
const lower = q.toLowerCase();
for (const [label, hint, fn] of pages) {
if (!lower || label.toLowerCase().includes(lower)) out.push({ group: lower ? 'Pages' : 'Go to', label, hint, run: go(fn) });
}
if (q.length >= 2) {
// 3. Studies
for (const e of searchProjects(projectIndex, q)) {
out.push({ group: 'Studies', label: e.title, hint: e.chapterSummary, run: go(() => resumeProject(e.id)) });
}
// 4. Notes you wrote
const projects = loadProjectIndex().map((e) => loadProjectById(e.id)).filter(Boolean);
for (const h of searchNotes(projects, q)) {
out.push({
group: 'Your notes', label: `${h.ref} — ${h.projectTitle}`, hint: h.snippet,
run: go(() => {
const full = loadProjectById(h.projectId);
if (!full) return;
setProject({ ...full, selectedChunkId: h.chunkId });
setCurrentPage('study');
}),
});
}
// 5. Bookmarks and highlights
for (const m of searchMarks({ bookmarks: readerBookmarks, highlights: readerTextHighlights, labels: readerColorLabels ?? {} }, q)) {
out.push({
group: 'Bookmarks & highlights', label: `${m.bookName} ${m.chapter}:${m.verse}`, color: m.color,
hint: m.kind === 'highlight' ? `${m.label ? `${m.label}: ` : ''}${m.text ?? 'Highlight'}` : 'Bookmark',
run: go(() => { jumpToReaderVerse(m.book, m.chapter, m.verse); openBibleReader(); }),
});
}
// 6. The Bible's own text
out.push({ group: 'Search', label: `Search the Bible for “${q}”`, hint: 'Whole-Bible search in the reader', run: go(() => { requestReaderSearch(q); openBibleReader(); }) });
}
return out;
}, [query]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { setActive(0); }, [query]);
useEffect(() => { listRef.current?.querySelector('[aria-selected="true"]')?.scrollIntoView?.({ block: 'nearest' }); }, [active]);
const onKeyDown = (e) => {
if (e.key === 'ArrowDown') { e.preventDefault(); setActive((a) => Math.min(a + 1, items.length - 1)); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setActive((a) => Math.max(a - 1, 0)); }
else if (e.key === 'Enter') { e.preventDefault(); items[active]?.run(); }
else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onClose(); }
else if (e.key === 'Tab') { e.preventDefault(); } // the box and the list are one control
};
if (authUser === null && !authServerDown) return null; // the sign-in screen has nothing to search
let lastGroup = null;
return (
<div style={themeVars(readerTheme)} className="fixed inset-0 z-[90] flex items-start justify-center bg-black/40 px-3 pt-[12vh] font-sans" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div role="dialog" aria-modal="true" aria-label="Search and go to" className="w-full max-w-xl overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] text-[var(--r-text)] shadow-2xl">
<div className="flex items-center gap-2 border-b border-[var(--r-line)] px-4 py-3">
<span className="text-[var(--r-sub)]"><SearchIcon /></span>
<input
ref={inputRef} value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={onKeyDown}
role="combobox" aria-expanded="true" aria-controls="palette-list" aria-autocomplete="list"
aria-activedescendant={items[active] ? `palette-item-${active}` : undefined}
placeholder="Go to a passage, or search your studies and notes…"
className="min-w-0 flex-1 bg-transparent text-base outline-none placeholder:text-[var(--r-sub)]"
/>
<kbd className="hidden rounded border border-[var(--r-line)] px-1.5 py-0.5 text-[10px] text-[var(--r-sub)] sm:block">esc</kbd>
</div>
<ul id="palette-list" role="listbox" ref={listRef} className="max-h-[50vh] overflow-y-auto p-2">
{items.length === 0 && <li className="px-3 py-6 text-center text-sm text-[var(--r-sub)]">Nothing found. Try a book and chapter, like “romans 8”.</li>}
{items.map((item, i) => {
const header = item.group !== lastGroup ? item.group : null;
lastGroup = item.group;
return (
<li key={i} role="presentation">
{header && <p className="px-3 pb-1 pt-2 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">{header}</p>}
<div id={`palette-item-${i}`} role="option" aria-selected={i === active} onMouseMove={() => setActive(i)} onClick={item.run}
className={`flex cursor-pointer items-baseline gap-2 rounded-lg px-3 py-2 text-sm ${i === active ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : ''}`}>
{item.color && <span className="h-2.5 w-2.5 shrink-0 translate-y-px rounded-full border border-black/10" style={{ backgroundColor: item.color }} />}
<span className="shrink-0 font-medium">{item.label}</span>
{item.hint && <span className={`min-w-0 truncate text-xs ${i === active ? 'opacity-80' : 'text-[var(--r-sub)]'}`}>{item.hint}</span>}
</div>
</li>
);
})}
</ul>
<p className="border-t border-[var(--r-line)] px-4 py-2 text-[11px] text-[var(--r-sub)]">↑↓ to move · Enter to open · Esc to close</p>
</div>
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
// The "Save for offline" control: a button, or progress with a Stop button while a download runs.
import { canSaveOffline } from '../utils/offline.js';
export function DownloadProgress({ job, onCancel }) {
const pct = job.total ? Math.round((job.done / job.total) * 100) : 0;
return (
<div role="status" className="rounded-xl border border-[var(--r-line)] px-3 py-2 text-xs">
<div className="mb-1.5 flex items-center justify-between gap-2">
<span>Saving {job.label}… {job.done} of {job.total}</span>
<button type="button" onClick={onCancel} className="text-[var(--r-accent)] hover:underline">Stop</button>
</div>
<div className="h-1 overflow-hidden rounded-full bg-[var(--r-line)]"><div className="h-full bg-[var(--r-accent)] transition-[width]" style={{ width: `${pct}%` }} /></div>
</div>
);
}
// One book of a translation.
export function SaveBookOffline({ offline, translation, book, name }) {
if (!canSaveOffline(translation)) {
return <p className="mt-4 text-[11px] text-[var(--r-sub)]">Saving for offline works with the BSB and NET. The {translation}’s licence doesn’t allow it.</p>;
}
if (offline.job) return <div className="mt-4"><DownloadProgress job={offline.job} onCancel={offline.cancel} /></div>;
const saved = offline.isSaved(translation, book);
return (
<div className="mt-4 flex items-center justify-between gap-3 rounded-xl border border-[var(--r-line)] px-3 py-2 text-sm">
<span className={saved ? '' : 'text-[var(--r-sub)]'}>{saved ? `✓ ${name} (${translation}) is saved for offline` : `Read ${name} without a connection`}</span>
{!saved && (
<button type="button" onClick={() => offline.saveBook(translation, book, name)} className="shrink-0 rounded-full border border-[var(--r-line)] px-3 py-1 text-xs font-medium hover:bg-black/5">Save for offline</button>
)}
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
// An outline laid out for reading (not editing): numbered points, small type tags, notes, verse text. Used
// by the read-only share page. Styled to match the printed / PDF page.
import { labelFor } from '../utils/outlineLabels.js';
import { kindLabel } from '../utils/outlineLabels.js';
function Points({ nodes, depth }) {
return (
<ul className={depth === 0 ? '' : 'ml-6 sm:ml-10'}>
{nodes.map((n, i) => {
const main = depth === 0;
const mins = Number.isFinite(n.minutes) && n.minutes > 0;
return (
<li key={n.id} className={main ? 'mt-7 first:mt-5' : 'mt-2.5'}>
<div className="flex gap-3">
<span className={`shrink-0 font-semibold tabular-nums text-[var(--r-accent)] ${main ? 'w-9 text-lg' : 'w-8'}`}>{labelFor(depth, i)}</span>
<div className="min-w-0 flex-1">
<p className={`leading-snug ${main ? 'text-xl font-semibold' : 'text-[1.05rem]'}`}>
{n.kind && <span className="mr-2 inline-block -translate-y-px rounded-full border border-[#e4b9a3] px-2 py-0.5 align-middle font-sans text-[10px] font-semibold uppercase tracking-widest text-[var(--r-accent)]">{kindLabel(n.kind)}</span>}
{n.text.trim() || <span className="text-[var(--r-sub)]">(untitled)</span>}
{n.ref && <span className="ml-2 whitespace-nowrap font-sans text-[0.8rem] font-normal text-[var(--r-sub)]">{n.ref}</span>}
{mins && <span className="ml-2 whitespace-nowrap font-sans text-[0.8rem] font-normal text-[var(--r-sub)]">{n.minutes} min</span>}
</p>
{n.note.trim() && <p className="mt-1 whitespace-pre-line text-[0.95rem] italic leading-relaxed text-[var(--r-sub)]">{n.note.trim()}</p>}
{n.scripture?.text && (
<blockquote className="mt-2 border-l-[3px] border-[#e4b9a3] pl-4 italic leading-relaxed">
“{n.scripture.text}” <span className="whitespace-nowrap font-sans text-[0.8rem] not-italic text-[var(--r-sub)]">— {n.scripture.ref}</span>
</blockquote>
)}
{n.attachments.length > 0 && (
<p className="mt-1.5 font-sans text-[0.8rem] leading-relaxed text-[var(--r-sub)]">
{n.attachments.map((a, k) => <span key={a.id || k}>{k > 0 && ' · '}{a.type === 'word' ? 'Word' : 'See'}: {a.label}{a.detail ? ` — ${a.detail}` : ''}</span>)}
</p>
)}
{n.children.length > 0 && <Points nodes={n.children} depth={depth + 1} />}
</div>
</div>
</li>
);
})}
</ul>
);
}
export default function OutlineDocument({ title, meta, tree }) {
return (
<article className="mx-auto max-w-[44rem] px-5 pb-20 pt-10 font-serif text-[var(--r-text)] sm:px-8 sm:pt-14">
<p className="font-sans text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--r-accent)]">Outline</p>
<h1 className="mt-3 text-3xl font-semibold leading-tight tracking-tight sm:text-[2.1rem]">{title}</h1>
{meta && <p className="mt-2 font-sans text-sm text-[var(--r-sub)]">{meta}</p>}
<hr className="mb-1 mt-6 border-[var(--r-line)]" />
{tree.length > 0 ? <Points nodes={tree} depth={0} /> : <p className="mt-8 text-[var(--r-sub)]">Nothing in this outline yet.</p>}
<p className="mt-14 border-t border-[var(--r-line)] pt-4 text-center font-sans text-[11px] tracking-wide text-[var(--r-sub)]">Bible Study Project</p>
</article>
);
}
+66
View File
@@ -0,0 +1,66 @@
// Share an outline: a read-only link anyone can open (no account), or the plain text.
import { useState } from 'react';
import Sheet from './reader/Sheet.jsx';
import { useToast } from './Toasts.jsx';
import { useOutlineShareLink } from '../hooks/useOutlineShareLink.js';
import { shareOutline } from '../utils/outlineExport.js';
const btn = 'rounded-xl border border-[var(--r-line)] px-3.5 py-2 text-sm hover:bg-black/5 disabled:opacity-50';
export default function ShareOutlineSheet({ open, onClose, projectId, outline }) {
const { toast } = useToast();
const link = useOutlineShareLink(projectId, open);
const [copied, setCopied] = useState(false);
const copyLink = async () => {
try { await navigator.clipboard.writeText(link.url); setCopied(true); window.setTimeout(() => setCopied(false), 2000); } catch { toast('Couldn’t copy. Select the link and copy it.', { tone: 'error' }); }
};
// The phone/computer share sheet, with the link (falls back to copying it)
const shareLink = async () => {
if (navigator.share) {
try { await navigator.share({ title: `${outline.title} — Outline`, url: link.url }); return; } catch (err) { if (err?.name === 'AbortError') return; }
}
copyLink();
};
const shareText = 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' });
};
return (
<Sheet open={open} onClose={onClose} title="Share this outline">
<h3 className="text-sm font-medium">Read-only link</h3>
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">
Anyone with the link can read this study’s outlines (the whole-study one and each chunk’s), and print them. They can’t edit anything, and they can’t see your notes or scripts. No account needed.
</p>
{link.state === 'loading' && <p className="text-sm text-[var(--r-sub)]">Checking…</p>}
{link.state === 'unavailable' && (
<p className="rounded-xl border border-[var(--r-line)] px-3 py-2 text-sm text-[var(--r-sub)]">Links need your account and a connection to the server. You can still share the text below.</p>
)}
{link.state === 'ready' && (link.token ? (
<div className="space-y-2">
<div className="flex items-center gap-2">
<input readOnly aria-label="Outline link" value={link.url} onFocus={(e) => e.target.select()}
className="min-w-0 flex-1 truncate rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-xs text-[var(--r-sub)]" />
<button type="button" onClick={copyLink} className="shrink-0 rounded-lg bg-[var(--r-accent)] px-4 py-2 text-xs font-medium text-[var(--r-on)]">{copied ? 'Copied!' : 'Copy'}</button>
</div>
<div className="flex flex-wrap items-center gap-3">
<button type="button" onClick={shareLink} className={btn}>Share link…</button>
<button type="button" onClick={link.turnOff} disabled={link.busy} className="text-xs text-[var(--r-danger,#b42318)] hover:underline">{link.busy ? 'Turning off…' : 'Turn off link'}</button>
</div>
<p className="text-[11px] text-[var(--r-sub)]">People see the version that last synced to your account, and it updates as you edit. Turning the link off stops it working right away.</p>
</div>
) : (
<button type="button" onClick={link.create} disabled={link.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">{link.busy ? 'Creating…' : 'Create read-only link'}</button>
))}
{link.error && <p role="alert" className="mt-2 text-xs text-[var(--r-danger,#b42318)]">{link.error}</p>}
<div className="mt-5 border-t border-[var(--r-line)] pt-4">
<h3 className="text-sm font-medium">Or send the text</h3>
<p className="mb-2 mt-1 text-xs text-[var(--r-sub)]">The outline you’re looking at, as plain text, to paste into a message or email.</p>
<button type="button" onClick={shareText} className={btn}>Share as text</button>
</div>
</Sheet>
);
}
+70
View File
@@ -0,0 +1,70 @@
// What someone sees when they open a read-only outline link: a calm page with the outline, a switcher
// when there is more than one, and Print / PDF. Nothing is editable.
import { useEffect, useState } from 'react';
import { getSharedOutline } from '../syncService.js';
import { themeVars } from './reader/readerThemes.js';
import OutlineDocument from './OutlineDocument.jsx';
import { printOutline } from '../utils/outlineExport.js';
import { flatten } from '../utils/outline.js';
const pill = (active) => `shrink-0 rounded-full px-3.5 py-1.5 text-sm transition ${active ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`;
function Frame({ children }) {
return <div className="min-h-dvh bg-[var(--r-bg)] font-sans text-[var(--r-text)]" style={themeVars('paper')}>{children}</div>;
}
export default function SharedOutlineView({ token }) {
const [data, setData] = useState(null);
const [error, setError] = useState('');
const [scope, setScope] = useState('study');
useEffect(() => {
let active = true;
getSharedOutline(token).then((result) => {
if (!active) return;
if (result.ok) { setData(result.data); setScope(result.data.outlines[0]?.scope ?? 'study'); } else setError(result.error ?? 'This outline link is invalid or has been turned off.');
});
return () => { active = false; };
}, [token]);
useEffect(() => { if (data?.title) document.title = `${data.title} — Outline`; }, [data]);
if (error) {
return (
<Frame>
<div className="flex min-h-dvh items-center justify-center px-5">
<div className="max-w-sm text-center">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--r-accent)]">Bible Study Project</p>
<p className="mt-4 text-base">{error}</p>
</div>
</div>
</Frame>
);
}
if (!data) return <Frame><p className="flex min-h-dvh items-center justify-center text-sm text-[var(--r-sub)]">Loading outline…</p></Frame>;
const current = data.outlines.find((o) => o.scope === scope) ?? data.outlines[0];
const title = current && current.scope !== 'study' ? `${data.title} — ${current.label}` : data.title;
const minutes = current ? flatten(current.tree).reduce((sum, { node }) => sum + (Number.isFinite(node.minutes) ? node.minutes : 0), 0) : 0;
const meta = current ? [current.scope === 'study' ? data.translation : `${current.label} · ${data.translation}`, minutes > 0 ? `About ${minutes} minute${minutes === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · ') : '';
const outline = current ? { title, subtitle: current.scope === 'study' ? data.translation : `${current.label} · ${data.translation}`, tree: current.tree } : null;
return (
<Frame>
<header className="sticky top-0 z-10 border-b border-[var(--r-line)] bg-[var(--r-bar)]/95 backdrop-blur">
<div className="mx-auto flex max-w-[44rem] flex-wrap items-center gap-x-3 gap-y-2 px-5 py-2.5 sm:px-8">
<span className="shrink-0 text-sm font-medium">Bible Study</span>
{data.outlines.length > 1 ? (
<nav aria-label="Outline" className="order-last flex w-full min-w-0 gap-1 overflow-x-auto sm:order-none sm:w-auto sm:flex-1">
{data.outlines.map((o) => (
<button key={o.scope} type="button" onClick={() => setScope(o.scope)} aria-current={current?.scope === o.scope ? 'true' : undefined} className={pill(current?.scope === o.scope)}>{o.label}</button>
))}
</nav>
) : <span className="flex-1" />}
{outline && <button type="button" onClick={() => printOutline(outline)} className="ml-auto shrink-0 sm:ml-0 rounded-full border border-[var(--r-line)] px-3.5 py-1.5 text-sm hover:bg-black/5">Print / PDF</button>}
</div>
</header>
{current ? <OutlineDocument title={title} meta={meta} tree={current.tree} /> : <p className="p-10 text-center text-sm text-[var(--r-sub)]">This study doesn’t have an outline yet.</p>}
</Frame>
);
}
+64
View File
@@ -0,0 +1,64 @@
// 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'],
['C', 'Compare translations'],
[',', 'Reading settings'],
['F', 'Hide or show the controls'],
],
study: [
['← →', 'Previous / next chunk'],
],
};
export default function ShortcutsHelp({ onClose }) {
const { readerTheme, currentPage } = useApp();
const closeRef = useRef(null);
useEffect(() => {
const from = document.activeElement;
closeRef.current?.focus();
const onKey = (e) => { if (e.key === 'Escape' || e.key === '?') { e.preventDefault(); e.stopPropagation(); onClose(); } };
document.addEventListener('keydown', onKey, true);
return () => { document.removeEventListener('keydown', onKey, true); if (from instanceof HTMLElement && document.contains(from)) from.focus({ preventScroll: true }); };
}, [onClose]);
const page = BY_PAGE[currentPage] ?? [];
const section = (title, rows) => (
<div className="mb-4 last:mb-0">
<h3 className="mb-1.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">{title}</h3>
<dl className="space-y-1.5">
{rows.map(([keys, what]) => (
<div key={keys} className="flex items-center justify-between gap-4 text-sm">
<dt><kbd className="rounded-md border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-0.5 font-mono text-xs">{keys}</kbd></dt>
<dd className="text-right text-[var(--r-sub)]">{what}</dd>
</div>
))}
</dl>
</div>
);
return (
<div style={themeVars(readerTheme)} className="fixed inset-0 z-[90] flex items-center justify-center bg-black/40 px-3 font-sans" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div role="dialog" aria-modal="true" aria-label="Keyboard shortcuts" className="max-h-[80vh] w-full max-w-sm overflow-y-auto rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5 text-[var(--r-text)] shadow-2xl">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-base font-medium">Keyboard shortcuts</h2>
<button ref={closeRef} type="button" onClick={onClose} className="rounded-lg px-2 py-1 text-sm text-[var(--r-sub)] hover:bg-black/5">Close</button>
</div>
{page.length > 0 && section(currentPage === 'reader' ? 'In the Bible' : 'In this study', page)}
{section('Everywhere', GLOBAL)}
</div>
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
// App-wide toasts: short messages at the bottom of the screen, optionally with one
// action button (usually "Undo"). Used instead of alert()/confirm() so a slip is
// reversible and nothing blocks the page.
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { themeVars } from './reader/readerThemes.js';
const ToastContext = createContext(null);
// toast(message, { action: { label, onClick }, ms, tone: 'info' | 'error' }) -> id
// Outside a provider (a page rendered on its own in a test) it quietly does nothing.
const NOOP = { toast: () => 0, dismiss: () => {}, setTheme: () => {} };
export const useToast = () => useContext(ToastContext) ?? NOOP;
let nextId = 1;
export function ToastProvider({ children }) {
const [toasts, setToasts] = useState([]);
const [theme, setTheme] = useState('paper'); // set by the app so toasts match the reading theme
const timers = useRef(new Map());
const dismiss = useCallback((id) => {
window.clearTimeout(timers.current.get(id));
timers.current.delete(id);
setToasts((list) => list.filter((t) => t.id !== id));
}, []);
const toast = useCallback((message, { action, ms, tone = 'info' } = {}) => {
const id = nextId++;
// Messages with an action stay long enough to read and react to.
const duration = ms ?? (action ? 8000 : tone === 'error' ? 6000 : 3500);
setToasts((list) => [...list.slice(-2), { id, message, action, tone }]);
timers.current.set(id, window.setTimeout(() => dismiss(id), duration));
return id;
}, [dismiss]);
useEffect(() => {
const live = timers.current;
return () => live.forEach((t) => window.clearTimeout(t));
}, []);
const value = useMemo(() => ({ toast, dismiss, setTheme }), [toast, dismiss]);
return (
<ToastContext.Provider value={value}>
{children}
<div style={themeVars(theme)} className="pointer-events-none fixed inset-x-0 bottom-0 z-[100] flex flex-col items-center gap-2 px-4 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] font-sans">
{toasts.map((t) => (
<div key={t.id} role={t.tone === 'error' ? 'alert' : 'status'}
className={`pointer-events-auto flex max-w-md items-center gap-3 rounded-full px-4 py-2.5 text-sm shadow-lg ${t.tone === 'error' ? 'bg-[var(--r-danger)] text-[var(--r-bg)]' : 'bg-[var(--r-text)] text-[var(--r-bg)]'}`}>
<span>{t.message}</span>
{t.action && (
<button type="button" onClick={() => { t.action.onClick(); dismiss(t.id); }}
className="shrink-0 rounded-full px-2 py-0.5 text-sm font-semibold underline underline-offset-2 hover:opacity-80">
{t.action.label}
</button>
)}
</div>
))}
</div>
</ToastContext.Provider>
);
}
+26 -4
View File
@@ -5,7 +5,10 @@ import { useRef, useState } from 'react';
import Sheet from '../reader/Sheet.jsx'; import Sheet from '../reader/Sheet.jsx';
import TranslationSelect from './TranslationSelect.jsx'; import TranslationSelect from './TranslationSelect.jsx';
import { resizeImageToDataUrl } from './CustomPlanBuilder.jsx'; import { resizeImageToDataUrl } from './CustomPlanBuilder.jsx';
import { formatDayReadings, getPlanSchedule, daysBehindPace } from '../../utils/readingPlans.js'; import { formatDayReadings, getPlanSchedule, daysBehindPace, nextUnreadDayIndex } from '../../utils/readingPlans.js';
import { useOfflineSave } from '../../hooks/useOfflineSave.js';
import { DownloadProgress } from '../OfflineSave.jsx';
import { canSaveOffline } from '../../utils/offline.js';
const btn = 'rounded-xl border border-[var(--r-line)] px-3 py-2 text-sm hover:bg-black/5'; const btn = 'rounded-xl border border-[var(--r-line)] px-3 py-2 text-sm hover:bg-black/5';
const primary = 'w-full rounded-xl bg-[var(--r-accent)] py-2.5 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50'; const primary = 'w-full rounded-xl bg-[var(--r-accent)] py-2.5 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50';
@@ -14,6 +17,13 @@ function ActiveBody({ plan, progress, onClose, actions }) {
const schedule = getPlanSchedule(plan.id); const schedule = getPlanSchedule(plan.id);
const behind = daysBehindPace(plan, progress); const behind = daysBehindPace(plan, progress);
const pct = plan.days > 0 ? Math.round((progress.completedDays.length / plan.days) * 100) : 0; const pct = plan.days > 0 ? Math.round((progress.completedDays.length / plan.days) * 100) : 0;
const offline = useOfflineSave();
const translation = progress.translation ?? actions.defaultTranslation;
// The next week of readings, so a trip or a bad connection doesn't interrupt the plan
const upcoming = (() => {
const from = nextUnreadDayIndex(plan, progress.completedDays);
return from == null ? [] : schedule.slice(from, from + 7).flat();
})();
return ( return (
<> <>
<div className="mb-3 flex items-center justify-between text-sm"> <div className="mb-3 flex items-center justify-between text-sm">
@@ -42,9 +52,21 @@ function ActiveBody({ plan, progress, onClose, actions }) {
); );
})} })}
</div> </div>
{upcoming.length > 0 && canSaveOffline(translation) && (
<div className="mt-3">
{offline.job
? <DownloadProgress job={offline.job} onCancel={offline.cancel} />
: (
<button type="button" onClick={() => offline.saveRefs('the next 7 days', translation, upcoming)}
className="w-full rounded-xl border border-[var(--r-line)] px-3 py-2 text-xs text-[var(--r-sub)] hover:bg-black/5">
Save the next 7 days for offline ({translation})
</button>
)}
</div>
)}
<div className="mt-4 flex items-center justify-between text-xs"> <div className="mt-4 flex items-center justify-between text-xs">
<button type="button" onClick={() => { if (window.confirm('Restart this plan from day 1?')) { actions.restart(progress.id); onClose(); } }} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">Restart</button> <button type="button" onClick={() => { actions.restart(progress.id); onClose(); }} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">Restart</button>
<button type="button" onClick={() => { if (window.confirm('Remove this plan?')) { actions.remove(progress.id); onClose(); } }} className="text-[var(--r-danger,#b42318)] hover:underline">Remove</button> <button type="button" onClick={() => { actions.remove(progress.id); onClose(); }} className="text-[var(--r-danger,#b42318)] hover:underline">Remove</button>
</div> </div>
</> </>
); );
@@ -92,7 +114,7 @@ function CatalogBody({ plan, active, completedCount, onClose, actions }) {
<button type="button" onClick={() => { onClose(); actions.edit(plan.id); }} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">Edit plan</button> <button type="button" onClick={() => { onClose(); actions.edit(plan.id); }} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">Edit plan</button>
<button type="button" onClick={() => fileRef.current?.click()} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">{plan.image ? 'Change cover' : 'Add cover photo'}</button> <button type="button" onClick={() => fileRef.current?.click()} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">{plan.image ? 'Change cover' : 'Add cover photo'}</button>
{plan.image && <button type="button" onClick={() => actions.updateCover(plan.id, null)} className="text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]">Remove cover</button>} {plan.image && <button type="button" onClick={() => actions.updateCover(plan.id, null)} className="text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]">Remove cover</button>}
<button type="button" onClick={() => { if (window.confirm('Delete this plan? Its progress history will be removed too.')) { actions.delete(plan.id); onClose(); } }} className="ml-auto text-[var(--r-danger,#b42318)] hover:underline">Delete</button> <button type="button" onClick={() => { actions.delete(plan.id); onClose(); }} className="ml-auto text-[var(--r-danger,#b42318)] hover:underline">Delete</button>
<input ref={fileRef} type="file" accept="image/*" onChange={pickImage} className="hidden" /> <input ref={fileRef} type="file" accept="image/*" onChange={pickImage} className="hidden" />
{imageError && <p className="w-full text-[var(--r-danger,#b42318)]">{imageError}</p>} {imageError && <p className="w-full text-[var(--r-danger,#b42318)]">{imageError}</p>}
</div> </div>
-20
View File
@@ -1,20 +0,0 @@
import Sheet from './Sheet.jsx';
import { CloseIcon } from './icons.jsx';
export default function BookmarksSheet({ open, onClose, entries, onJump, onRemove }) {
return (
<Sheet open={open} onClose={onClose} title={`Bookmarks${entries.length ? ` (${entries.length})` : ''}`}>
{entries.length === 0 ? (
<p className="text-sm text-[var(--r-sub)]">No bookmarks yet. Tap a verse, then the bookmark icon, to save it here.</p>
) : entries.map((e) => (
<div key={e.key} className="flex items-center gap-2 rounded-lg px-1 py-1.5 hover:bg-black/5">
<span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: e.color }} />
<button type="button" onClick={() => onJump(e.bookAbbrev, e.chapter, e.verse)} className="flex-1 truncate text-left text-sm">
{e.bookName} {e.chapter}:{e.verse}
</button>
<button type="button" onClick={() => onRemove(e.key)} aria-label="Remove bookmark" className="rounded p-1 text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]"><CloseIcon /></button>
</div>
))}
</Sheet>
);
}
+35
View File
@@ -0,0 +1,35 @@
// Compare translations: pick a second Bible to read beside the one you're reading, and whether the
// words that differ are highlighted. The chapter shows how different the two are.
import Sheet from './Sheet.jsx';
import { READER_TRANSLATIONS } from '../../utils/bibleText.js';
export default function CompareSheet({ open, onClose, translation, parallel, onParallel, showDiff, onShowDiff }) {
const chip = (active) => `rounded-full border px-3 py-1.5 text-sm 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'}`;
const options = READER_TRANSLATIONS.filter((t) => t.id !== translation);
return (
<Sheet open={open} onClose={onClose} title="Compare translations">
<p className="mb-3 text-sm text-[var(--r-sub)]">
Read another Bible beside the {translation}. You’ll see how different its wording is for the whole chapter and for each verse.
</p>
<span className="mb-1.5 block text-xs text-[var(--r-sub)]">Compare {translation} with</span>
<div role="group" aria-label="Translation to compare with" className="mb-4 flex flex-wrap gap-1.5">
{options.map((t) => (
<button key={t.id} type="button" title={t.name} aria-pressed={parallel === t.id} onClick={() => { onParallel(parallel === t.id ? '' : t.id); if (parallel !== t.id) onClose(); }} className={chip(parallel === t.id)}>{t.id}</button>
))}
<button type="button" aria-pressed={!parallel} onClick={() => onParallel('')} className={chip(!parallel)}>Off</button>
</div>
{parallel && (
<>
<span className="mb-1.5 block text-xs text-[var(--r-sub)]">Highlight the words that differ</span>
<div role="group" aria-label="Highlight differences" className="flex overflow-hidden rounded-xl border border-[var(--r-line)]">
{[[true, 'Show'], [false, 'Hide']].map(([id, text]) => (
<button key={String(id)} type="button" aria-pressed={showDiff === id} onClick={() => onShowDiff(id)}
className={`flex-1 px-3 py-2 text-sm ${showDiff === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{text}</button>
))}
</div>
</>
)}
<p className="mt-4 text-[11px] text-[var(--r-sub)]">The wording is compared word by word, ignoring capitals and punctuation. It shows how the wording differs, not which translation is more accurate. Tap any verse and open Compare to see all six translations of it.</p>
</Sheet>
);
}
+13
View File
@@ -0,0 +1,13 @@
// Text with the words that differ from the other translation marked.
export function DiffText({ text, ranges = [] }) {
if (!ranges.length) return text;
const parts = [];
let cursor = 0;
ranges.forEach((r, i) => {
if (r.start > cursor) parts.push(text.slice(cursor, r.start));
parts.push(<span key={i} className="diff-hl">{text.slice(r.start, r.end)}</span>);
cursor = r.end;
});
if (cursor < text.length) parts.push(text.slice(cursor));
return <>{parts}</>;
}
+94
View File
@@ -0,0 +1,94 @@
// Everything you have marked in the Bible: highlights (grouped and filtered by colour, each colour
// named by you — "Promises", "Commands") and bookmarks. Opens from the bookmark button in the top bar.
import { useState } from 'react';
import Sheet from './Sheet.jsx';
import { CloseIcon } from './icons.jsx';
import { COLOR_NAMES, COLOR_SUGGESTIONS, HIGHLIGHT_COLORS, colorTitle } from '../../utils/highlightColors.js';
const tabBtn = (active) => `rounded-full border px-3 py-1 text-xs font-medium ${active ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`;
export default function MarksSheet({
open, onClose, bookmarks, highlights, colorLabels, onSetLabel, onJump, onRemoveBookmark, onRemoveHighlight,
}) {
const [tab, setTab] = useState('highlights');
const [filter, setFilter] = useState(null); // a highlight colour, or null for all
const [editing, setEditing] = useState(false);
const counts = Object.fromEntries(HIGHLIGHT_COLORS.map((c) => [c, highlights.filter((h) => h.color === c).length]));
const shown = filter ? highlights.filter((h) => h.color === filter) : highlights;
// Colours in use that aren't one of the picker's (older data) still get listed
const total = highlights.length + bookmarks.length;
return (
<Sheet open={open} onClose={onClose} label="Bookmarks and highlights" title={`Bookmarks & highlights${total ? ` (${total})` : ''}`}>
<div role="tablist" aria-label="What to show" className="mb-3 flex gap-1.5">
<button type="button" role="tab" aria-selected={tab === 'highlights'} onClick={() => setTab('highlights')} className={tabBtn(tab === 'highlights')}>Highlights ({highlights.length})</button>
<button type="button" role="tab" aria-selected={tab === 'bookmarks'} onClick={() => setTab('bookmarks')} className={tabBtn(tab === 'bookmarks')}>Bookmarks ({bookmarks.length})</button>
</div>
{tab === 'highlights' && (
<>
{/* Colour key: tap a colour to show only that one; "Name colours" lets you say what each means */}
<div className="mb-3 flex flex-wrap items-center gap-1.5" role="group" aria-label="Filter by colour">
<button type="button" onClick={() => setFilter(null)} aria-pressed={filter === null} className={tabBtn(filter === null)}>All</button>
{HIGHLIGHT_COLORS.map((c) => (
<button key={c} type="button" onClick={() => setFilter(filter === c ? null : c)} aria-pressed={filter === c}
title={colorTitle(c, colorLabels)}
className={`flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs ${filter === c ? 'border-[var(--r-text)]' : 'border-[var(--r-line)] hover:bg-black/5'}`}>
<span className="h-3 w-3 rounded-full border border-black/10" style={{ backgroundColor: c }} />
<span>{colorLabels[c] || COLOR_NAMES[c]}</span>
<span className="text-[var(--r-sub)]">{counts[c]}</span>
</button>
))}
<button type="button" onClick={() => setEditing((e) => !e)} className="ml-auto text-xs text-[var(--r-accent)] hover:underline">{editing ? 'Done' : 'Name colours'}</button>
</div>
{editing && (
<div className="mb-3 grid gap-2 rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-3 sm:grid-cols-2">
{HIGHLIGHT_COLORS.map((c) => (
<label key={c} className="flex items-center gap-2 text-xs text-[var(--r-sub)]">
<span className="h-4 w-4 shrink-0 rounded-full border border-black/10" style={{ backgroundColor: c }} />
<span className="sr-only">{COLOR_NAMES[c]} means</span>
<input value={colorLabels[c] ?? ''} maxLength={30} onChange={(e) => onSetLabel(c, e.target.value)} placeholder={COLOR_SUGGESTIONS[c]}
className="min-w-0 flex-1 rounded-lg border border-[var(--r-line)] bg-transparent px-2 py-1 text-sm text-[var(--r-text)] outline-none focus:border-[var(--r-accent)]" />
</label>
))}
</div>
)}
{highlights.length === 0 ? (
<p className="text-sm text-[var(--r-sub)]">No highlights yet. Select some words in a verse and pick a colour to save them here.</p>
) : shown.length === 0 ? (
<p className="text-sm text-[var(--r-sub)]">Nothing highlighted in that colour yet.</p>
) : shown.map((h) => (
<div key={h.id} className="flex items-start gap-2 rounded-lg px-1 py-1.5 hover:bg-black/5">
<span className="mt-1 h-3 w-3 shrink-0 rounded-full border border-black/10" style={{ backgroundColor: h.color }} title={colorTitle(h.color, colorLabels)} />
<button type="button" onClick={() => onJump(h.book, h.chapter, h.verse)} className="min-w-0 flex-1 text-left text-sm">
<span className="font-medium">{h.bookName} {h.chapter}:{h.verse}</span>
{colorLabels[h.color] && <span className="ml-1.5 text-[11px] text-[var(--r-sub)]">{colorLabels[h.color]}</span>}
{h.text && <span className="mt-0.5 block font-serif text-[var(--r-sub)]">“{h.text}”</span>}
</button>
<button type="button" onClick={() => onRemoveHighlight(h)} aria-label={`Remove highlight on ${h.bookName} ${h.chapter}:${h.verse}`}
className="rounded p-1 text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]"><CloseIcon /></button>
</div>
))}
</>
)}
{tab === 'bookmarks' && (
bookmarks.length === 0 ? (
<p className="text-sm text-[var(--r-sub)]">No bookmarks yet. Tap a verse, then the bookmark icon, to save it here.</p>
) : bookmarks.map((e) => (
<div key={e.key} className="flex items-center gap-2 rounded-lg px-1 py-1.5 hover:bg-black/5">
<span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: e.color }} />
<button type="button" onClick={() => onJump(e.bookAbbrev, e.chapter, e.verse)} className="flex-1 truncate text-left text-sm">
{e.bookName} {e.chapter}:{e.verse}
</button>
<button type="button" onClick={() => onRemoveBookmark(e)} aria-label={`Remove bookmark on ${e.bookName} ${e.chapter}:${e.verse}`}
className="rounded p-1 text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]"><CloseIcon /></button>
</div>
))
)}
</Sheet>
);
}
+7 -3
View File
@@ -2,12 +2,13 @@
import { useState } from 'react'; import { useState } from 'react';
import Sheet from './Sheet.jsx'; import Sheet from './Sheet.jsx';
import { bookOptions, CHAPTER_COUNTS } from '../../utils/bibleUtils.jsx'; import { bookOptions, CHAPTER_COUNTS } from '../../utils/bibleUtils.jsx';
import { READER_TRANSLATIONS } from '../../hooks/useBibleReader.js'; import { READER_TRANSLATIONS } from '../../utils/bibleText.js';
import { SaveBookOffline } from '../OfflineSave.jsx';
const NT_START = bookOptions.findIndex((b) => b.abbrev === 'MAT'); const NT_START = bookOptions.findIndex((b) => b.abbrev === 'MAT');
const chip = 'rounded-lg border px-2 py-2 text-center text-sm transition'; const chip = 'rounded-lg border px-2 py-2 text-center text-sm transition';
export default function NavigatorSheet({ open, onClose, bookAbbrev, chapter, translation, onTranslation, translationNote, onGo }) { export default function NavigatorSheet({ open, onClose, bookAbbrev, chapter, translation, onTranslation, translationNote, onGo, offline }) {
const [pickedBook, setPickedBook] = useState(null); // book abbrev whose chapters are showing const [pickedBook, setPickedBook] = useState(null); // book abbrev whose chapters are showing
const close = () => { setPickedBook(null); onClose(); }; const close = () => { setPickedBook(null); onClose(); };
const book = pickedBook ? bookOptions.find((b) => b.abbrev === pickedBook) : null; const book = pickedBook ? bookOptions.find((b) => b.abbrev === pickedBook) : null;
@@ -20,7 +21,7 @@ export default function NavigatorSheet({ open, onClose, bookAbbrev, chapter, tra
); );
return ( return (
<Sheet open={open} onClose={close} title={book ? ( <Sheet open={open} onClose={close} label="Go to" title={book ? (
<button type="button" onClick={() => setPickedBook(null)} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">‹ All books · <span className="text-[var(--r-text)]">{book.name}</span></button> <button type="button" onClick={() => setPickedBook(null)} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">‹ All books · <span className="text-[var(--r-text)]">{book.name}</span></button>
) : 'Go to'}> ) : 'Go to'}>
{!book && ( {!book && (
@@ -41,6 +42,7 @@ export default function NavigatorSheet({ open, onClose, bookAbbrev, chapter, tra
</> </>
)} )}
{book && ( {book && (
<>
<div className="grid grid-cols-6 gap-1.5 sm:grid-cols-8"> <div className="grid grid-cols-6 gap-1.5 sm:grid-cols-8">
{Array.from({ length: CHAPTER_COUNTS[book.abbrev] ?? 1 }, (_, i) => i + 1).map((n) => ( {Array.from({ length: CHAPTER_COUNTS[book.abbrev] ?? 1 }, (_, i) => i + 1).map((n) => (
<button key={n} type="button" onClick={() => { onGo(book.abbrev, n); close(); }} <button key={n} type="button" onClick={() => { onGo(book.abbrev, n); close(); }}
@@ -49,6 +51,8 @@ export default function NavigatorSheet({ open, onClose, bookAbbrev, chapter, tra
</button> </button>
))} ))}
</div> </div>
{offline && <SaveBookOffline offline={offline} translation={translation} book={book.abbrev} name={book.name} />}
</>
)} )}
</Sheet> </Sheet>
); );
+3 -2
View File
@@ -1,11 +1,11 @@
// Top and bottom chrome for the reader. Everything stays one tap away; focus // Top and bottom chrome for the reader. Everything stays one tap away; focus
// mode hides both. // mode hides both.
import { ChevronLeft, ChevronRight, ChevronDown, SearchIcon, LinkIcon, PenIcon, HeadphonesIcon, FocusIcon, CheckIcon } from './icons.jsx'; import { ChevronLeft, ChevronRight, ChevronDown, SearchIcon, LinkIcon, PenIcon, HeadphonesIcon, FocusIcon, CheckIcon, CompareIcon } from './icons.jsx';
import { BookmarkIcon } from '../../utils/bibleUtils.jsx'; import { BookmarkIcon } from '../../utils/bibleUtils.jsx';
const iconBtn = 'flex h-9 w-9 items-center justify-center rounded-lg text-[var(--r-sub)] transition hover:bg-black/5 disabled:opacity-30 disabled:hover:bg-transparent'; const iconBtn = 'flex h-9 w-9 items-center justify-center rounded-lg text-[var(--r-sub)] transition hover:bg-black/5 disabled:opacity-30 disabled:hover:bg-transparent';
export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks, bookmarkCount, onSettings, onDoneDrawing }) { export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks, bookmarkCount, onCompare, compareActive, onSettings, onDoneDrawing }) {
return ( return (
<div className="safe-top sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)] font-sans"> <div className="safe-top sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)] font-sans">
<div className="mx-auto flex h-12 max-w-4xl items-center gap-1 px-2"> <div className="mx-auto flex h-12 max-w-4xl items-center gap-1 px-2">
@@ -25,6 +25,7 @@ export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks,
<button type="button" onClick={onBookmarks} className={`${iconBtn} relative ${bookmarkCount ? 'text-[var(--r-accent)]' : ''}`} aria-label="Bookmarks"> <button type="button" onClick={onBookmarks} className={`${iconBtn} relative ${bookmarkCount ? 'text-[var(--r-accent)]' : ''}`} aria-label="Bookmarks">
<BookmarkIcon filled={bookmarkCount > 0} /> <BookmarkIcon filled={bookmarkCount > 0} />
</button> </button>
<button type="button" onClick={onCompare} className={`${iconBtn} ${compareActive ? 'text-[var(--r-accent)]' : ''}`} aria-label="Compare translations" aria-pressed={!!compareActive} title="Compare translations"><CompareIcon /></button>
<button type="button" onClick={onSettings} className={`${iconBtn} w-10 text-sm font-medium`} aria-label="Reading settings">Aa</button> <button type="button" onClick={onSettings} className={`${iconBtn} w-10 text-sm font-medium`} aria-label="Reading settings">Aa</button>
</> </>
)} )}
+112 -10
View File
@@ -1,12 +1,33 @@
// The page itself: chapter heading, translation notice, and verses set in a // The page itself: chapter heading, translation notice, and verses set in a
// 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 { useMemo } from 'react';
import { formatCrossRef } from '../../utils/wordStudy.js'; import { formatCrossRef } from '../../utils/wordStudy.js';
import { diffChapter, differenceLabel } from '../../utils/textDiff.js';
import { DiffText } from './DiffText.jsx';
// 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, showDiff = true,
bookmarks, bookKey, chapter, selectedVerse, onSelectVerse, renderVerseText, searchLower, bookmarks, bookKey, chapter, selectedVerse, onSelectVerse, renderVerseText, searchLower,
crossRefs, showCrossRefs, onSelectionEnd, footer, crossRefs, showCrossRefs, onSelectionEnd, footer,
}) { }) {
// How much the second Bible's wording differs from the first, over the chapter and verse by verse
const diff = useMemo(
() => (parallel && showDiff && !parallel.loading && !parallel.error && parallel.verses?.length ? diffChapter(verses, parallel.verses) : null),
[parallel, showDiff, verses],
);
const diffByVerse = useMemo(() => new Map((diff?.verses ?? []).map((d) => [d.number, d])), [diff]);
const jumpToVerse = (n) => document.getElementById(`reader-verse-${n}`)?.scrollIntoView?.({ block: 'center', behavior: window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
const handleTap = (n) => { const handleTap = (n) => {
const sel = window.getSelection(); const sel = window.getSelection();
if (sel && !sel.isCollapsed && sel.toString().trim()) return; // user is selecting text, not tapping if (sel && !sel.isCollapsed && sel.toString().trim()) return; // user is selecting text, not tapping
@@ -20,7 +41,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 +61,90 @@ 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">
{diff && (
<div className="mb-4 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-3 font-sans text-sm" style={{ fontSize: '0.875rem' }}>
<p>
<strong>{parallel.translation}</strong>{diff.similarity === 1 ? ' uses the same words as ' : <> is <strong>{differenceLabel(diff.similarity)}</strong> from </>}{translationId} in this chapter
{diff.total > 0 && <span className="text-[var(--r-sub)]"> · {diff.differing} of {diff.total} verses differ</span>}
</p>
{diff.mostDifferent.length > 0 && (
<p className="mt-1 text-xs text-[var(--r-sub)]">
Most different:{' '}
{diff.mostDifferent.map((v, i) => (
<span key={v.number}>{i > 0 && ', '}<button type="button" onClick={() => jumpToVerse(v.number)} className="font-medium text-[var(--r-accent)] hover:underline">verse {v.number}</button> ({Math.round((1 - v.similarity) * 100)}%)</span>
))}
</p>
)}
<p className="mt-1 text-xs text-[var(--r-sub)]"><span className="diff-hl">Marked words</span> aren’t in the other wording.</p>
</div>
)}
<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, diffByVerse.get(row.number)?.a)}{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)}<DiffText text={row.second.text} ranges={diffByVerse.get(row.number)?.b} />{diffByVerse.get(row.number) && !diffByVerse.get(row.number).same && (
<span className="ml-2 whitespace-nowrap font-sans text-[10px] text-[var(--r-sub)]">{differenceLabel(diffByVerse.get(row.number).similarity)}</span>
)}</> : 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 +164,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>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { vi, test, expect } from 'vitest';
import ReaderText from './ReaderText.jsx';
const verses = [
{ number: 1, text: 'In the beginning God created the heavens and the earth.' },
{ number: 2, text: 'Now the earth was formless and void.' },
];
const second = [
{ number: 1, text: 'In the beginning God created the heavens and the earth.' },
{ number: 2, text: 'Now the earth was without shape and empty.' },
];
const base = {
title: 'Genesis 1', translation: 'Berean Standard Bible', translationId: 'BSB', notice: '', verses, loading: false, error: '',
layout: 'flow', fontSize: 1, wide: false, bookmarks: {}, bookKey: 'GEN', chapter: 1, selectedVerse: null,
onSelectVerse: vi.fn(), renderVerseText: (_n, text) => text, searchLower: '', crossRefs: null, showCrossRefs: false, onSelectionEnd: vi.fn(),
};
const parallel = (over = {}) => ({ translation: 'NET', name: 'NET Bible', notice: '', verses: second, loading: false, error: '', ...over });
test('side by side, it says how different the other Bible is and marks the differing words', () => {
const { container } = render(<ReaderText {...base} parallel={parallel()} showDiff />);
expect(screen.getByText(/is/, { selector: 'p' })).toHaveTextContent(/NET is \d+% different from BSB in this chapter/);
expect(screen.getByText(/1 of 2 verses differ/)).toBeInTheDocument();
const marked = [...container.querySelectorAll('.diff-hl')].map((n) => n.textContent);
expect(marked).toContain('without shape'); // the other Bible's side
expect(screen.getByRole('button', { name: /verse 2/i })).toBeInTheDocument(); // the most different verse
expect(screen.getByText(/% different/, { selector: 'span.ml-2' })).toBeInTheDocument(); // per-verse figure
});
test('identical wording is reported as the same words', () => {
render(<ReaderText {...base} parallel={parallel({ verses })} showDiff />);
expect(screen.getByText(/uses the same words as/)).toBeInTheDocument();
});
test('with highlighting off there is no summary and no marks', () => {
const { container } = render(<ReaderText {...base} parallel={parallel()} showDiff={false} />);
expect(screen.queryByText(/different from BSB/)).not.toBeInTheDocument();
expect(container.querySelectorAll('.diff-hl')).toHaveLength(0);
});
test('while the other Bible loads there is no comparison yet, and the first Bible still shows', () => {
render(<ReaderText {...base} parallel={parallel({ verses: [], loading: true })} showDiff />);
expect(screen.queryByText(/different from BSB/)).not.toBeInTheDocument();
expect(screen.getAllByText(/In the beginning God created/).length).toBeGreaterThan(0);
});
test('the left Bible gets its own differing words passed to the verse renderer', () => {
const renderVerseText = vi.fn((_n, text) => text);
render(<ReaderText {...base} renderVerseText={renderVerseText} parallel={parallel()} showDiff />);
const call = renderVerseText.mock.calls.find(([n]) => n === 2);
expect(call[3].map((r) => verses[1].text.slice(r.start, r.end))).toEqual(['formless', 'void']);
fireEvent.click(screen.getByRole('button', { name: /verse 2/i })); // jumping must not throw even if the element is absent
});
+27 -11
View File
@@ -1,42 +1,58 @@
// "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';
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,
}) {
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>
</Sheet> </Sheet>
); );
+37 -3
View File
@@ -2,17 +2,51 @@
// close on outside tap; non-modal ones (the verse sheet) leave the text // close on outside tap; non-modal ones (the verse sheet) leave the text
// tappable. With `pane`, screens >= lg show it as a right-hand side pane // tappable. With `pane`, screens >= lg show it as a right-hand side pane
// instead of a bottom sheet (tablet landscape / desktop). // instead of a bottom sheet (tablet landscape / desktop).
import { useEffect, useRef } from 'react';
import { CloseIcon } from './icons.jsx'; import { CloseIcon } from './icons.jsx';
export default function Sheet({ open, onClose, title, headerRight, modal = true, pane = false, children }) { const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
export default function Sheet({ open, onClose, title, label, headerRight, modal = true, pane = false, children }) {
const dialogRef = useRef(null);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
// Modal sheets behave like dialogs: focus moves in, Tab stays inside, Escape closes, and focus
// returns to whatever opened the sheet. (The verse pane is not modal, so it leaves focus alone.)
useEffect(() => {
if (!open || !modal) return undefined;
const opener = document.activeElement;
const dialog = dialogRef.current;
if (dialog && !dialog.contains(document.activeElement)) dialog.focus({ preventScroll: true });
const onKey = (e) => {
if (e.key === 'Escape') { e.stopPropagation(); onCloseRef.current?.(); return; }
if (e.key !== 'Tab' || !dialog) return;
const items = [...dialog.querySelectorAll(FOCUSABLE)].filter((el) => el.offsetParent !== null || el === document.activeElement);
if (items.length === 0) { e.preventDefault(); return; }
const first = items[0];
const last = items[items.length - 1];
if (e.shiftKey && (document.activeElement === first || document.activeElement === dialog)) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
};
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('keydown', onKey);
if (opener instanceof HTMLElement && document.contains(opener)) opener.focus({ preventScroll: true });
};
}, [open, modal]);
if (!open) return null; if (!open) return null;
return ( return (
<> <>
{modal && <div className="fixed inset-0 z-40 bg-black/30" onClick={onClose} aria-hidden="true" />} {modal && <div className="fixed inset-0 z-40 bg-black/30" onClick={onClose} aria-hidden="true" />}
<div <div
ref={dialogRef}
role="dialog" role="dialog"
aria-label={typeof title === 'string' ? title : undefined} aria-modal={modal ? 'true' : undefined}
className={`fixed inset-x-0 bottom-0 z-50 flex max-h-[72vh] flex-col rounded-t-2xl border-t border-[var(--r-line)] bg-[var(--r-bar)] text-[var(--r-text)] font-sans ${ aria-label={typeof title === 'string' ? title : label}
tabIndex={-1}
className={`fixed inset-x-0 bottom-0 z-50 flex max-h-[72vh] flex-col rounded-t-2xl focus:outline-none border-t border-[var(--r-line)] bg-[var(--r-bar)] text-[var(--r-text)] font-sans ${
pane ? 'lg:inset-x-auto lg:bottom-0 lg:right-0 lg:top-12 lg:max-h-none lg:w-[24rem] lg:rounded-none lg:border-l lg:border-t-0' : 'sm:left-1/2 sm:right-auto sm:w-[36rem] sm:-translate-x-1/2' pane ? 'lg:inset-x-auto lg:bottom-0 lg:right-0 lg:top-12 lg:max-h-none lg:w-[24rem] lg:rounded-none lg:border-l lg:border-t-0' : 'sm:left-1/2 sm:right-auto sm:w-[36rem] sm:-translate-x-1/2'
}`} }`}
> >
+52 -3
View File
@@ -5,8 +5,11 @@ 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';
import { diffVerses, differenceLabel } from '../../utils/textDiff.js';
import { DiffText } from './DiffText.jsx';
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 +52,51 @@ 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]);
const mine = rows[current]?.text;
return (
<div className="space-y-3">
{READER_TRANSLATIONS.map((t) => {
const row = rows[t.id];
// How this wording differs from the Bible being read, word by word
const d = row?.text && mine && t.id !== current ? diffVerses(mine, row.text) : null;
return (
<div key={t.id}>
<p className="mb-0.5 flex items-center gap-2 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">
<span>{t.id}{t.id === current ? ' · reading' : ''}</span>
{d && <span className="normal-case tracking-normal">{differenceLabel(d.similarity)}</span>}
</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"><DiffText text={row.text} ranges={d?.b} /></p>}
</div>
);
})}
<p className="border-t border-[var(--r-line)] pt-2 text-[10px] leading-snug text-[var(--r-sub)]">
{mine ? <><span className="diff-hl">Marked words</span> aren’t in the {current} wording. </> : null}
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 +134,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} />}
+1
View File
@@ -11,3 +11,4 @@ export const PenIcon = () => <svg {...base}><path d="M13.5 3.5l3 3L7 16l-4 1 1-4
export const HeadphonesIcon = () => <svg {...base}><path d="M3.5 12V10a6.5 6.5 0 0113 0v2" /><rect x="2.5" y="11.5" width="3.5" height="5" rx="1.2" /><rect x="14" y="11.5" width="3.5" height="5" rx="1.2" /></svg>; export const HeadphonesIcon = () => <svg {...base}><path d="M3.5 12V10a6.5 6.5 0 0113 0v2" /><rect x="2.5" y="11.5" width="3.5" height="5" rx="1.2" /><rect x="14" y="11.5" width="3.5" height="5" rx="1.2" /></svg>;
export const FocusIcon = () => <svg {...base}><path d="M3 7V4h3M17 7V4h-3M3 13v3h3M17 13v3h-3" /></svg>; export const FocusIcon = () => <svg {...base}><path d="M3 7V4h3M17 7V4h-3M3 13v3h3M17 13v3h-3" /></svg>;
export const CheckIcon = () => <svg {...base}><path d="M4.5 10.5l3.5 3.5 7.5-8" /></svg>; export const CheckIcon = () => <svg {...base}><path d="M4.5 10.5l3.5 3.5 7.5-8" /></svg>;
export const CompareIcon = () => <svg {...base}><rect x="2.5" y="3.5" width="6.5" height="13" rx="1.3" /><rect x="11" y="3.5" width="6.5" height="13" rx="1.3" /><path d="M4.5 7h2.5M4.5 10h2.5M13 7h2.5M13 10h2.5" /></svg>;
+3 -3
View File
@@ -3,15 +3,15 @@
export const READER_THEMES = { export const READER_THEMES = {
paper: { paper: {
label: 'Paper', label: 'Paper',
vars: { '--r-bg': '#faf7f0', '--r-text': '#2b2620', '--r-sub': '#7d7466', '--r-accent': '#b4532a', '--r-bar': '#f1ece0', '--r-line': '#e0d8c6', '--r-hl': '#f6e7a8', '--r-on': '#ffffff', '--r-danger': '#b42318' }, vars: { '--r-bg': '#faf7f0', '--r-text': '#2b2620', '--r-sub': '#6b6458', '--r-accent': '#a84e27', '--r-bar': '#f1ece0', '--r-line': '#e0d8c6', '--r-hl': '#f6e7a8', '--r-on': '#ffffff', '--r-danger': '#b42318' },
}, },
sepia: { sepia: {
label: 'Sepia', label: 'Sepia',
vars: { '--r-bg': '#f1e4c8', '--r-text': '#3b2f1c', '--r-sub': '#7d6b48', '--r-accent': '#9a4a1e', '--r-bar': '#e8d8b6', '--r-line': '#d5c197', '--r-hl': '#e9cf85', '--r-on': '#ffffff', '--r-danger': '#a1281d' }, vars: { '--r-bg': '#f1e4c8', '--r-text': '#3b2f1c', '--r-sub': '#635539', '--r-accent': '#93471d', '--r-bar': '#e8d8b6', '--r-line': '#d5c197', '--r-hl': '#e9cf85', '--r-on': '#ffffff', '--r-danger': '#a1281d' },
}, },
dark: { dark: {
label: 'Dark', label: 'Dark',
vars: { '--r-bg': '#1c1b19', '--r-text': '#e6e1d6', '--r-sub': '#9a9385', '--r-accent': '#e0895c', '--r-bar': '#262421', '--r-line': '#3a3733', '--r-hl': '#4a4224', '--r-on': '#1c1b19', '--r-danger': '#f0857a' }, vars: { '--r-bg': '#1c1b19', '--r-text': '#e6e1d6', '--r-sub': '#b8b2a4', '--r-accent': '#e0895c', '--r-bar': '#262421', '--r-line': '#3a3733', '--r-hl': '#4a4224', '--r-on': '#1c1b19', '--r-danger': '#f0857a' },
}, },
}; };
@@ -0,0 +1,27 @@
import { describe, expect, test } from 'vitest';
import { READER_THEMES } from './readerThemes.js';
// WCAG relative luminance and contrast ratio
const luminance = (hex) => {
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const contrast = (a, b) => {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
};
// Small text needs 4.5:1. These are the pairings the app actually draws.
describe.each(Object.entries(READER_THEMES))('%s theme contrast', (_id, { vars }) => {
const v = (name) => vars[`--r-${name}`];
test.each([
['text', 'bg'], ['text', 'bar'], ['text', 'hl'],
['sub', 'bg'], ['sub', 'bar'], ['sub', 'hl'],
['accent', 'bg'], ['accent', 'bar'],
['danger', 'bg'], ['danger', 'bar'],
['on', 'accent'],
])('%s on %s is at least 4.5:1', (fg, bg) => {
expect(contrast(v(fg), v(bg))).toBeGreaterThanOrEqual(4.5);
});
});
+113
View File
@@ -0,0 +1,113 @@
// Settings → "Your data": download everything as one file, restore from one, and save Bibles for offline.
import { useRef, useState } from 'react';
import { useApp } from '../../context/AppContext.js';
import { useToast } from '../Toasts.jsx';
import { DownloadProgress } from '../OfflineSave.jsx';
import { useOfflineSave } from '../../hooks/useOfflineSave.js';
import { backupFileName, buildBackup, restoreProjects, validateBackup } from '../../utils/backup.js';
import { saveRemoteProject } from '../../syncService.js';
const btn = 'rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5 disabled:opacity-50';
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
export default function DataSection() {
const {
authUser, readerBookmarks, readerTextHighlights, readerColorLabels, readerInkByPage, readingPlansState,
importReaderMarks, importReadingPlans, refreshProjectIndex,
} = useApp();
const { toast } = useToast();
const offline = useOfflineSave();
const fileRef = useRef(null);
const [pending, setPending] = useState(null); // { name, backup, summary } waiting for "Restore"
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const download = () => {
const backup = buildBackup({
email: authUser?.email ?? null,
reader: { bookmarks: readerBookmarks, highlights: readerTextHighlights, colorLabels: readerColorLabels, ink: readerInkByPage },
readingPlans: readingPlansState,
});
const url = URL.createObjectURL(new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' }));
const link = document.createElement('a');
link.href = url;
link.download = backupFileName();
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
toast(`Backup downloaded: ${plural(backup.projects.length, 'study', 'studies')} and your reading marks`);
};
const choose = async (e) => {
const file = e.target.files?.[0];
e.target.value = '';
setError('');
setPending(null);
if (!file) return;
let data;
try { data = JSON.parse(await file.text()); } catch { setError('That file couldn’t be read as a backup.'); return; }
const checked = validateBackup(data);
if (!checked.ok) { setError(checked.error); return; }
setPending({ name: file.name, ...checked });
};
const restore = async () => {
setBusy(true);
const { backup } = pending;
const written = restoreProjects(backup.projects);
importReaderMarks(backup.reader);
importReadingPlans(backup.readingPlans);
refreshProjectIndex();
// Studies restored here also go to the account, so they follow you to other devices
if (authUser) await Promise.all(written.map((p) => saveRemoteProject(p)));
setBusy(false);
setPending(null);
toast(`Restored ${plural(written.length, 'study', 'studies')}, plus your bookmarks, highlights and plans`);
};
const s = pending?.summary;
return (
<section id="backup" aria-label="Your data" className="scroll-mt-16 space-y-5 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6">
<div>
<h2 className="text-base font-medium">Your data</h2>
<p className="mt-1 text-sm text-[var(--r-sub)]">Everything you’ve made — studies and notes, bookmarks, highlights, ink and reading plans — in one file you keep.</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<button type="button" onClick={download} className={btn}>Download backup</button>
<button type="button" onClick={() => fileRef.current?.click()} className={btn}>Restore from a backup…</button>
<input ref={fileRef} type="file" accept="application/json,.json" onChange={choose} className="hidden" aria-label="Backup file" />
</div>
{error && <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{error}</p>}
{pending && (
<div className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-4 text-sm">
<p className="font-medium">{pending.name}</p>
<p className="mt-1 text-[var(--r-sub)]">
Contains {plural(s.projects, 'study', 'studies')}, {plural(s.bookmarks, 'bookmark')}, {plural(s.highlights, 'highlight')}, {plural(s.inkPages, 'ink page')} and {plural(s.plans, 'reading plan')}.
Restoring adds these to what you have — nothing is deleted, and a study on this device is only replaced if the backup’s copy is newer.
</p>
<div className="mt-3 flex gap-2">
<button type="button" onClick={restore} disabled={busy} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50">{busy ? 'Restoring…' : 'Restore'}</button>
<button type="button" onClick={() => setPending(null)} className={btn}>Cancel</button>
</div>
</div>
)}
<div className="border-t border-[var(--r-line)] pt-4">
<h3 className="text-sm font-medium">Read offline</h3>
<p className="mb-3 mt-1 text-sm text-[var(--r-sub)]">Chapters you read are kept automatically. To read anywhere, save a whole Bible while you’re connected (about 4 MB). The ESV, NLT, CSB and NASB can’t be saved: their licences don’t allow it.</p>
{offline.job ? <DownloadProgress job={offline.job} onCancel={offline.cancel} /> : (
<div className="flex flex-wrap gap-3">
{['BSB', 'NET'].map((t) => (
<button key={t} type="button" onClick={() => offline.saveBible(t)} className={btn}>
{(offline.saved[t] ?? []).length >= 66 ? `✓ The whole ${t} is saved` : `Save the whole ${t}`}
</button>
))}
</div>
)}
</div>
</section>
);
}
+1 -1
View File
@@ -8,7 +8,7 @@ export default function OutlineRail({ project, selectedId, onSelect, onEditOutli
return ( return (
<nav aria-label="Chunks" className="flex h-full min-h-0 flex-col"> <nav aria-label="Chunks" className="flex h-full min-h-0 flex-col">
<div className="flex items-center justify-between px-3 pb-2 pt-3"> <div className="flex items-center justify-between px-3 pb-2 pt-3">
<span className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Outline</span> <span className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Chunks</span>
<button type="button" onClick={onEditOutline} className="text-[11px] text-[var(--r-accent)] hover:underline">Edit</button> <button type="button" onClick={onEditOutline} className="text-[11px] text-[var(--r-accent)] hover:underline">Edit</button>
</div> </div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-4"> <div className="min-h-0 flex-1 overflow-y-auto px-2 pb-4">
+23 -9
View File
@@ -3,12 +3,13 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useApp } from '../../context/AppContext.js'; import { useApp } from '../../context/AppContext.js';
import { themeVars, READER_THEMES } from '../reader/readerThemes.js'; import { themeVars, READER_THEMES } from '../reader/readerThemes.js';
import { ChevronLeft } from '../reader/icons.jsx'; import { ChevronLeft, SearchIcon } from '../reader/icons.jsx';
// `short` is what a phone shows; the full label is still the button's accessible name.
const NAV = [ const NAV = [
{ id: 'studies', label: 'Studies' }, { id: 'studies', label: 'Studies', short: 'Studies' },
{ id: 'reader', label: 'Read Bible' }, { id: 'reader', label: 'Read Bible', short: 'Bible' },
{ id: 'plans', label: 'Reading Plans' }, { id: 'plans', label: 'Reading Plans', short: 'Plans' },
]; ];
// Avatar button with a dropdown: who you are, the theme, Settings, Admin, Log out. // Avatar button with a dropdown: who you are, the theme, Settings, Admin, Log out.
@@ -61,25 +62,37 @@ function AccountMenu() {
} }
export default function PageShell({ title, onBack, actions, active = null, maxWidth = 'max-w-6xl', children }) { export default function PageShell({ title, onBack, actions, active = null, maxWidth = 'max-w-6xl', children }) {
const { readerTheme, goHome, goStudies, openBibleReader, openReadingPlans } = useApp(); const { readerTheme, goHome, goStudies, openBibleReader, openReadingPlans, openPalette } = useApp();
const go = { studies: goStudies, reader: openBibleReader, plans: openReadingPlans }; const go = { studies: goStudies, reader: openBibleReader, plans: openReadingPlans };
return ( return (
<div className="min-h-dvh bg-[var(--r-bg)] font-sans text-[var(--r-text)]" style={themeVars(readerTheme)}> <div className="min-h-dvh bg-[var(--r-bg)] font-sans text-[var(--r-text)]" style={themeVars(readerTheme)}>
<a href="#content" className="skip-link">Skip to content</a>
<header className="safe-top sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)]"> <header className="safe-top sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)]">
<div className={`mx-auto flex h-12 ${maxWidth} items-center gap-4 px-4`}> <div className={`mx-auto flex h-12 ${maxWidth} items-center gap-2 px-3 sm:gap-4 sm:px-4`}>
<button type="button" onClick={goHome} className="flex shrink-0 items-center gap-2" aria-label="Bible Study home"> <button type="button" onClick={goHome} className="flex shrink-0 items-center gap-2" aria-label="Bible Study home">
<img src="/logo.svg" alt="" className="h-6 w-6" /> <img src="/logo.svg" alt="" className="h-6 w-6" />
<span className="hidden text-sm font-medium sm:inline">Bible Study</span> <span className="hidden text-sm font-medium sm:inline">Bible Study</span>
</button> </button>
<nav aria-label="Main" className="flex items-center gap-0.5"> <nav aria-label="Main" className="flex items-center gap-0.5">
{NAV.map((n) => ( {NAV.map((n) => (
<button key={n.id} type="button" onClick={go[n.id]} aria-current={active === n.id ? 'page' : undefined} <button key={n.id} type="button" onClick={go[n.id]} aria-current={active === n.id ? 'page' : undefined} aria-label={n.label}
className={`rounded-full px-3 py-1.5 text-sm ${active === n.id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{n.label}</button> className={`whitespace-nowrap rounded-full px-2.5 py-1.5 text-sm sm:px-3 ${active === n.id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>
<span className="sm:hidden" aria-hidden="true">{n.short}</span><span className="hidden sm:inline" aria-hidden="true">{n.label}</span>
</button>
))} ))}
</nav> </nav>
<div className="ml-auto"><AccountMenu /></div> <div className="ml-auto flex items-center gap-2">
{openPalette && (
<button type="button" onClick={openPalette} aria-label="Search and go to"
className="flex items-center gap-2 rounded-full border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-1 text-xs text-[var(--r-sub)] hover:bg-black/5">
<SearchIcon /><span className="hidden sm:inline">Search</span><kbd className="hidden rounded border border-[var(--r-line)] px-1 text-[10px] md:inline">⌘K</kbd>
</button>
)}
<AccountMenu />
</div>
</div> </div>
</header> </header>
<div id="content" tabIndex={-1} className="focus:outline-none">
<div className={`mx-auto flex ${maxWidth} flex-wrap items-center gap-3 px-4 pt-6`}> <div className={`mx-auto flex ${maxWidth} flex-wrap items-center gap-3 px-4 pt-6`}>
{onBack && ( {onBack && (
<button type="button" onClick={onBack} aria-label="Back" className="-ml-2 flex h-9 w-9 items-center justify-center rounded-lg text-[var(--r-sub)] hover:bg-black/5"><ChevronLeft /></button> <button type="button" onClick={onBack} aria-label="Back" className="-ml-2 flex h-9 w-9 items-center justify-center rounded-lg text-[var(--r-sub)] hover:bg-black/5"><ChevronLeft /></button>
@@ -89,5 +102,6 @@ export default function PageShell({ title, onBack, actions, active = null, maxWi
</div> </div>
{children} {children}
</div> </div>
</div>
); );
} }
+13 -3
View File
@@ -6,8 +6,9 @@ import { themeVars, READER_THEMES } from '../reader/readerThemes.js';
import { ChevronLeft } from '../reader/icons.jsx'; import { ChevronLeft } from '../reader/icons.jsx';
export const STAGES = [ export const STAGES = [
{ id: 'setup', label: 'Outline' }, { id: 'setup', label: 'Chunks' },
{ id: 'study', label: 'Study' }, { id: 'study', label: 'Study' },
{ id: 'outline', label: 'Outline' },
{ id: 'script', label: 'Script' }, { id: 'script', label: 'Script' },
{ id: 'export', label: 'Export' }, { id: 'export', label: 'Export' },
]; ];
@@ -16,7 +17,7 @@ const THEME_ORDER = ['paper', 'sepia', 'dark'];
export default function StudioShell({ stage, loading = false, fixedHeight = false, children }) { export default function StudioShell({ stage, loading = false, fixedHeight = false, children }) {
const { const {
goHome, setCurrentPage, project, allChunks, goHome, setCurrentPage, project, allChunks,
saveStatus, syncStatus, retrySync, saveStatus, syncStatus, retrySync, syncConflict, keepMyVersion, useServerVersion,
readerTheme, setReaderTheme, readerTheme, setReaderTheme,
} = useApp(); } = useApp();
const noChunks = allChunks.length === 0; const noChunks = allChunks.length === 0;
@@ -34,7 +35,7 @@ export default function StudioShell({ stage, loading = false, fixedHeight = fals
const disabled = s.id !== 'setup' && (!project || noChunks); const disabled = s.id !== 'setup' && (!project || noChunks);
return ( return (
<button key={s.id} type="button" disabled={disabled} aria-current={stage === s.id ? 'step' : undefined} <button key={s.id} type="button" disabled={disabled} aria-current={stage === s.id ? 'step' : undefined}
title={disabled ? 'Add a chunk in Outline first' : undefined} title={disabled ? 'Add a chunk in Chunks first' : undefined}
onClick={() => setCurrentPage(s.id)} onClick={() => setCurrentPage(s.id)}
className={`rounded-full px-3.5 py-1.5 text-xs ${stage === s.id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'} disabled:opacity-40 disabled:hover:bg-transparent`}> className={`rounded-full px-3.5 py-1.5 text-xs ${stage === s.id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'} disabled:opacity-40 disabled:hover:bg-transparent`}>
{s.label} {s.label}
@@ -54,6 +55,15 @@ export default function StudioShell({ stage, loading = false, fixedHeight = fals
</div> </div>
</div> </div>
</div> </div>
{syncConflict && project && syncConflict.id === project.id && (
<div role="alert" className="flex shrink-0 flex-wrap items-center justify-center gap-x-4 gap-y-2 border-b border-[var(--r-line)] bg-[var(--r-hl)] px-4 py-2.5 text-sm">
<span>This study was changed on another device. Your edits here are saved on this device, but not yet synced.</span>
<span className="flex gap-2">
<button type="button" onClick={keepMyVersion} className="rounded-full bg-[var(--r-text)] px-3 py-1 text-xs font-medium text-[var(--r-bg)] hover:opacity-90">Keep this version</button>
<button type="button" onClick={useServerVersion} className="rounded-full border border-[var(--r-text)] px-3 py-1 text-xs font-medium hover:bg-black/5">Use the other version</button>
</span>
</div>
)}
{children} {children}
</div> </div>
); );
+106 -35
View File
@@ -6,21 +6,29 @@ import { bookOptions, CHAPTER_COUNTS, parseBibleChapter } from '../utils/bibleUt
import { useReaderMarksSync } from './useReaderMarksSync.js'; import { useReaderMarksSync } from './useReaderMarksSync.js';
import { usePrefsSync } from './usePrefsSync.js'; import { usePrefsSync } from './usePrefsSync.js';
import { COMMENTARIES } from './useVerseStudy.js'; import { COMMENTARIES } from './useVerseStudy.js';
import { READER_TRANSLATIONS, fetchChapter } from '../utils/bibleText.js';
import { mergeMarks, normalizeMarks } from '../utils/readerMarks.js';
import { BIBLE_IDB_TTL, getBibleIndexFromIDB, saveBibleIndexToIDB } from '../utils/bibleIndexCache.js'; import { BIBLE_IDB_TTL, getBibleIndexFromIDB, saveBibleIndexToIDB } from '../utils/bibleIndexCache.js';
export const READER_TRANSLATIONS = [ export { READER_TRANSLATIONS };
{ id: 'BSB', name: 'Berean Standard Bible' },
{ id: 'ESV', name: 'English Standard Version', licensed: true, // A per-device choice kept in localStorage: useStoredChoice('reader-font', 'serif', ['serif', 'sans']).
notice: 'Scripture quotations are from the ESV® Bible (The Holy Bible, English Standard Version®), © 2001 by Crossway, a publishing ministry of Good News Publishers. Used by permission. All rights reserved.' }, function useStoredChoice(key, fallback, allowed) {
{ id: 'NET', name: 'NET Bible', helloao: 'eng_net', const [value, setValue] = useState(() => {
notice: 'Scripture quoted by permission. Quotations designated (NET) are from the NET Bible® copyright ©1996-2017 by Biblical Studies Press, L.L.C. http://netbible.com All rights reserved.' }, try {
{ id: 'NLT', name: 'New Living Translation', licensed: true, const saved = localStorage.getItem(key);
notice: 'Holy Bible, New Living Translation, Copyright © 2014, Tyndale House Publishers. All rights reserved. tyndale.com' }, return saved !== null && allowed.includes(saved) ? saved : fallback;
{ id: 'CSB', name: 'Christian Standard Bible', licensed: true, } catch { return fallback; }
notice: 'Christian Standard Bible® and CSB® are federally registered trademarks of Holman Bible Publishers. All rights reserved. bhpublishinggroup.com' }, });
{ id: 'NASB', name: 'New American Standard Bible', licensed: true, const set = (next) => {
notice: 'New American Standard Bible®, Copyright © 1995, The Lockman Foundation. All rights reserved. lockman.org' }, setValue(next);
]; try { localStorage.setItem(key, next); } catch {}
};
return [value, set];
}
export const READER_FONTS = ['serif', 'sans', 'easy'];
export const READER_SPACINGS = ['snug', 'normal', 'airy'];
const BOOKMARK_COLORS = ['#fef08a', '#bbf7d0', '#bfdbfe', '#fecaca', '#e9d5ff']; const BOOKMARK_COLORS = ['#fef08a', '#bbf7d0', '#bfdbfe', '#fecaca', '#e9d5ff'];
@@ -91,6 +99,12 @@ 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 [readerShowDiff, setReaderShowDiffRaw] = useStoredChoice('reader-show-diff', '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 +150,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 +239,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 +272,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 +363,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 +411,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 +453,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 +498,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 +507,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 +529,11 @@ 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,
readerShowDiff: readerShowDiff === 'on', setReaderShowDiff: (on) => setReaderShowDiffRaw(on ? 'on' : 'off'),
planTranslationPref, setPlanTranslationPref, planTranslationPref, setPlanTranslationPref,
startPagePref, setStartPagePref, startPagePref, setStartPagePref,
readerBookmarks, setReaderBookmarks, readerBookmarks, setReaderBookmarks,
@@ -480,11 +547,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 +564,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
readerGoToNextChapter, readerGoToNextChapter,
jumpToReaderVerse, jumpToReaderVerse,
toggleReaderBookmark, toggleReaderBookmark,
restoreReaderBookmark,
cycleBookmarkColor, cycleBookmarkColor,
loadReaderCrossRefs, loadReaderCrossRefs,
}; };
+28
View File
@@ -2,6 +2,7 @@
// active chapter tab, and every way of creating chunks (click, shift-click, // active chapter tab, and every way of creating chunks (click, shift-click,
// typed range, bulk ranges, cross-chapter spans), plus reorder and delete. // typed range, bulk ranges, cross-chapter spans), plus reorder and delete.
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useToast } from '../components/Toasts.jsx';
import { bookOptions, CHAPTER_COUNTS, makeId, parseBibleChapter } from '../utils/bibleUtils.jsx'; import { bookOptions, CHAPTER_COUNTS, makeId, parseBibleChapter } from '../utils/bibleUtils.jsx';
// Every chunk starts blank; all note fields exist from the start. // Every chunk starts blank; all note fields exist from the start.
@@ -99,6 +100,7 @@ function initialChapterIndex(project) {
} }
export function useChunkBuilder({ project, setProject, updateProject, allChunks, setCurrentPage }) { export function useChunkBuilder({ project, setProject, updateProject, allChunks, setCurrentPage }) {
const { toast } = useToast();
const [setup, setSetup] = useState(() => initialSetupForm(project)); const [setup, setSetup] = useState(() => initialSetupForm(project));
const [titleEdited, setTitleEdited] = useState(false); const [titleEdited, setTitleEdited] = useState(false);
const [activeChapterIndex, setActiveChapterIndex] = useState(() => initialChapterIndex(project)); const [activeChapterIndex, setActiveChapterIndex] = useState(() => initialChapterIndex(project));
@@ -531,7 +533,33 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
})); }));
}; };
// Deleting a chunk also deletes its notes, so it can be taken back for a few seconds.
const deleteChunk = (chunkId) => { const deleteChunk = (chunkId) => {
let removed = null;
(project?.chapters ?? []).forEach((ch, chapterIndex) => {
const index = ch.chunks.findIndex((c) => c.id === chunkId);
if (index >= 0) removed = { chunk: ch.chunks[index], chapterIndex, index, wasSelected: project.selectedChunkId === chunkId };
});
if (removed) {
toast('Chunk deleted', {
action: {
label: 'Undo',
onClick: () => updateProject((current) => {
if (current.chapters.some((ch) => ch.chunks.some((c) => c.id === chunkId))) return current;
return {
...current,
selectedChunkId: removed.wasSelected ? chunkId : current.selectedChunkId,
chapters: current.chapters.map((ch, i) => {
if (i !== removed.chapterIndex) return ch;
const chunks = [...ch.chunks];
chunks.splice(Math.min(removed.index, chunks.length), 0, removed.chunk);
return { ...ch, chunks };
}),
};
}),
},
});
}
updateProject((current) => { updateProject((current) => {
const newAllChunks = current.chapters const newAllChunks = current.chapters
.flatMap((ch) => ch.chunks) .flatMap((ch) => ch.chunks)
+42
View File
@@ -0,0 +1,42 @@
// Makes the browser's (and phone's) Back/Forward buttons move between the app's pages. The app keeps
// its page in state rather than in the URL, so without this Back would leave the app altogether.
// Each page change adds a history entry; Back/Forward set the page again from that entry.
import { useEffect, useRef } from 'react';
// Pages that only make sense with a study project open.
export const NEEDS_PROJECT = ['study', 'outline', 'script', 'export'];
export function useHistoryNav({ page, setPage, hasProject, onArrive, enabled = true }) {
const shown = useRef(page);
const hasProjectRef = useRef(hasProject);
hasProjectRef.current = hasProject;
const onArriveRef = useRef(onArrive);
onArriveRef.current = onArrive;
// First entry: record where the app started so Back can return to it.
useEffect(() => {
if (!enabled) return;
try { window.history.replaceState({ page }, ''); } catch { /* history unavailable */ }
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// A page change made inside the app adds a history entry.
useEffect(() => {
if (!enabled || shown.current === page) return;
shown.current = page;
try { window.history.pushState({ page }, ''); } catch { /* history unavailable */ }
}, [page, enabled]);
useEffect(() => {
if (!enabled) return undefined;
const onPop = (e) => {
let target = e.state?.page;
if (!target) return; // an entry that isn't ours (e.g. a hash change)
if (NEEDS_PROJECT.includes(target) && !hasProjectRef.current) target = 'home';
shown.current = target; // so the effect above doesn't push this page again
setPage(target);
onArriveRef.current?.(target);
};
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, [enabled, setPage]);
}
+52
View File
@@ -0,0 +1,52 @@
// Drives "Save for offline": one download at a time, with progress for the button to show,
// a cancel, and a toast when it finishes.
import { useCallback, useEffect, useRef, useState } from 'react';
import { useToast } from '../components/Toasts.jsx';
import { bibleRefs, bookRefs, downloadChapters, loadSavedBooks, markBookSaved } from '../utils/offline.js';
export function useOnline() {
const [online, setOnline] = useState(() => (typeof navigator === 'undefined' ? true : navigator.onLine !== false));
useEffect(() => {
const up = () => setOnline(true);
const down = () => setOnline(false);
window.addEventListener('online', up);
window.addEventListener('offline', down);
return () => { window.removeEventListener('online', up); window.removeEventListener('offline', down); };
}, []);
return online;
}
export function useOfflineSave() {
const { toast } = useToast();
const [saved, setSaved] = useState(loadSavedBooks);
const [job, setJob] = useState(null); // { label, done, total } while downloading
const controller = useRef(null);
useEffect(() => () => controller.current?.abort(), []);
const run = useCallback(async (label, translation, refs, { bookKeys = [] } = {}) => {
if (controller.current) return; // one at a time
const abort = new AbortController();
controller.current = abort;
setJob({ label, done: 0, total: refs.length });
const result = await downloadChapters(translation, refs, { signal: abort.signal, onProgress: (done, total) => setJob({ label, done, total }) });
controller.current = null;
setJob(null);
if (abort.signal.aborted) { toast('Download stopped'); return; }
if (result.failed === 0) {
bookKeys.forEach((b) => markBookSaved(translation, b));
setSaved(loadSavedBooks());
toast(`Saved ${label} for offline`);
} else {
toast(`Saved ${result.done} of ${refs.length} chapters — check your connection and try again`, { tone: 'error' });
}
}, [toast]);
return {
saved, job,
isSaved: (translation, book) => (saved[translation] ?? []).includes(book),
saveBook: (translation, book, name) => run(name, translation, bookRefs(book), { bookKeys: [book] }),
saveBible: (translation) => run(`The whole ${translation}`, translation, bibleRefs(), { bookKeys: [...new Set(bibleRefs().map((r) => r.book))] }),
saveRefs: (label, translation, refs) => run(label, translation, refs),
cancel: () => controller.current?.abort(),
};
}
+44
View File
@@ -0,0 +1,44 @@
// The read-only link to a study's outlines: status, create, turn off, copy. A separate link from the
// whole-study share link, and it only ever shows outlines.
import { useEffect, useState } from 'react';
import { disableOutlineSharing, enableOutlineSharing, getOutlineShareStatus } from '../syncService.js';
export function useOutlineShareLink(projectId, enabled = true) {
const [token, setToken] = useState(null);
const [state, setState] = useState('loading'); // 'loading' | 'ready' | 'unavailable' (no account / server)
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
setToken(null);
setError('');
if (!projectId || !enabled) return undefined;
let active = true;
setState('loading');
getOutlineShareStatus(projectId).then((result) => {
if (!active) return;
if (result.ok) { setToken(result.data.shareToken); setState('ready'); } else setState('unavailable');
});
return () => { active = false; };
}, [projectId, enabled]);
const url = token ? `${window.location.origin}${window.location.pathname}?outline=${token}` : '';
const create = async () => {
setBusy(true);
setError('');
const result = await enableOutlineSharing(projectId);
setBusy(false);
if (result.ok) setToken(result.data.shareToken);
else setError(result.status === 404 ? 'Your study hasn’t synced to your account yet. Wait a moment and try again.' : 'Couldn’t create the link. Check your connection and try again.');
};
const turnOff = async () => {
setBusy(true);
const result = await disableOutlineSharing(projectId);
setBusy(false);
if (result.ok) setToken(null); else setError('Couldn’t turn the link off. Try again.');
};
return { token, url, state, busy, error, create, turnOff };
}
+25
View File
@@ -0,0 +1,25 @@
// The second Bible in the side-by-side view: loads the same chapter in another translation.
// Pass an empty translation to switch it off.
import { useEffect, useState } from 'react';
import { fetchChapter } from '../utils/bibleText.js';
export function useParallelChapter(translation, bookAbbrev, chapter) {
const [state, setState] = useState({ key: '', verses: [], error: '' });
const key = translation ? `${translation}/${bookAbbrev}/${chapter}` : '';
useEffect(() => {
if (!key) return undefined;
const controller = new AbortController();
fetchChapter(translation, bookAbbrev, chapter, { signal: controller.signal })
.then(({ verses }) => setState({ key, verses, error: '' }))
.catch((err) => { if (!controller.signal.aborted) setState({ key, verses: [], error: err.message || 'Unable to load.' }); });
return () => controller.abort();
}, [key]); // eslint-disable-line react-hooks/exhaustive-deps
const ready = state.key === key;
return {
verses: ready ? state.verses : [],
error: ready ? state.error : '',
loading: !!key && !ready,
};
}
+3 -2
View File
@@ -5,7 +5,7 @@
import { useCallback, useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import { getRemotePrefs, saveRemotePrefs } from '../syncService.js'; import { getRemotePrefs, saveRemotePrefs } from '../syncService.js';
export const PREF_KEYS = ['theme', 'translation', 'layout', 'commentarySource', 'planTranslation', 'startPage', 'position']; export const PREF_KEYS = ['theme', 'translation', 'layout', 'commentarySource', 'planTranslation', 'startPage', 'position', 'colorLabels'];
const META_KEY = 'prefs-meta'; // { key: timestamp of the last change made on this device } const META_KEY = 'prefs-meta'; // { key: timestamp of the last change made on this device }
const PUSH_DELAY_MS = 1000; const PUSH_DELAY_MS = 1000;
const FOCUS_PULL_MIN_MS = 30_000; const FOCUS_PULL_MIN_MS = 30_000;
@@ -58,7 +58,7 @@ export function usePrefsSync({ values, apply, canApplyPosition = () => true }) {
window.clearTimeout(timerRef.current); window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(push, PUSH_DELAY_MS); timerRef.current = window.setTimeout(push, PUSH_DELAY_MS);
} }
}, [values.theme, values.translation, values.layout, values.commentarySource, values.planTranslation, values.startPage, values.position.book, values.position.chapter, push]); // eslint-disable-line react-hooks/exhaustive-deps }, [values.theme, values.translation, values.layout, values.commentarySource, values.planTranslation, values.startPage, values.position.book, values.position.chapter, values.colorLabels, push]); // eslint-disable-line react-hooks/exhaustive-deps
const pullServerPrefs = useCallback(async () => { const pullServerPrefs = useCallback(async () => {
enabledRef.current = true; enabledRef.current = true;
@@ -69,6 +69,7 @@ export function usePrefsSync({ values, apply, canApplyPosition = () => true }) {
const meta = readMeta(); const meta = readMeta();
const now = Date.now(); const now = Date.now();
for (const key of PREF_KEYS) { for (const key of PREF_KEYS) {
if (valuesRef.current[key] === undefined) continue; // nothing on this device to compare or upload
const remote = server[key]; const remote = server[key];
const localAt = meta[key] ?? 0; const localAt = meta[key] ?? 0;
if (remote && remote.at > localAt) { if (remote && remote.at > localAt) {
+50 -8
View File
@@ -1,6 +1,8 @@
// The signed-in user's saved projects: opening, renaming, deleting, and keeping // The signed-in user's saved projects: opening, renaming, deleting, and keeping
// this device's copies in step with the server. // this device's copies in step with the server.
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useToast } from '../components/Toasts.jsx';
import { clearSyncBase, getSyncBase, setSyncBase } from '../utils/syncBase.js';
import { loadProjectIndex } from '../utils/bibleUtils.jsx'; import { loadProjectIndex } from '../utils/bibleUtils.jsx';
import { import {
saveProjectToStorage, saveProjectToStorage,
@@ -21,6 +23,9 @@ export function useProjectLibrary({
setProjectIndex, setProjectIndex,
setCurrentPage, setCurrentPage,
}) { }) {
const { toast } = useToast();
// Deleted projects waiting out their "Undo" window before the server copy is removed too
const pendingDeletes = useRef(new Map()); // id -> timer
const [staleLocalProjects, setStaleLocalProjects] = useState([]); // projects where server is newer const [staleLocalProjects, setStaleLocalProjects] = useState([]); // projects where server is newer
const [autoRestoredCount, setAutoRestoredCount] = useState(null); // brief "synced from another device" toast const [autoRestoredCount, setAutoRestoredCount] = useState(null); // brief "synced from another device" toast
@@ -39,11 +44,17 @@ export function useProjectLibrary({
listRemoteProjects().then(async (result) => { listRemoteProjects().then(async (result) => {
if (!result.ok || cancelled) return; if (!result.ok || cancelled) return;
const localMap = new Map(localIndex.map((e) => [e.id, e])); const localMap = new Map(localIndex.map((e) => [e.id, e]));
// What this device has seen of each study on the server: a save made on top of anything older is
// checked against it, so a newer copy from another device is never overwritten silently.
for (const e of result.data) {
const local = localMap.get(e.id);
setSyncBase(e.id, local ? Math.min(e.lastEdited ?? 0, local.lastEdited ?? 0) : e.lastEdited);
}
const missing = result.data.filter((e) => !localMap.has(e.id)); const missing = result.data.filter((e) => !localMap.has(e.id));
if (missing.length > 0) { if (missing.length > 0) {
const fetched = await Promise.all(missing.map((e) => loadRemoteProject(e.id))); const fetched = await Promise.all(missing.map((e) => loadRemoteProject(e.id)));
fetched.filter((r) => r.ok).forEach((r) => saveProjectToStorage(r.data)); fetched.filter((r) => r.ok).forEach((r) => { saveProjectToStorage(r.data); setSyncBase(r.data.id, r.data.lastEdited); });
if (cancelled) return; if (cancelled) return;
const restoredCount = fetched.filter((r) => r.ok).length; const restoredCount = fetched.filter((r) => r.ok).length;
if (restoredCount > 0) { if (restoredCount > 0) {
@@ -78,26 +89,57 @@ export function useProjectLibrary({
const updated = { ...loaded, title, lastEdited: Date.now() }; const updated = { ...loaded, title, lastEdited: Date.now() };
saveProjectToStorage(updated); saveProjectToStorage(updated);
setProjectIndex(loadProjectIndex()); setProjectIndex(loadProjectIndex());
saveRemoteProject(updated); saveRemoteProject(updated, { baseEdited: getSyncBase(id) }).then((result) => {
if (result.ok) setSyncBase(id, updated.lastEdited);
else if (result.status === 409) toast('That study changed on another device. Open it to choose which version to keep.', { tone: 'error' });
});
if (project?.id === id) { if (project?.id === id) {
setProject((current) => (current ? { ...current, title } : current)); setProject((current) => (current ? { ...current, title } : current));
} }
}; };
const deleteProject = (id) => { // Deleting is immediate here, with an Undo for a few seconds; only after that is the server copy
if (!window.confirm('Delete this project? This cannot be undone.')) return; // removed (and straight away if the tab is closed first, so a deleted project never comes back).
deleteProjectFromStorage(id); const finishDelete = (id) => {
setProjectIndex(loadProjectIndex()); if (!pendingDeletes.current.has(id)) return;
window.clearTimeout(pendingDeletes.current.get(id));
pendingDeletes.current.delete(id);
deleteRemoteProject(id); // fire-and-forget deleteRemoteProject(id); // fire-and-forget
}; };
useEffect(() => {
const flush = () => [...pendingDeletes.current.keys()].forEach(finishDelete);
window.addEventListener('pagehide', flush);
return () => window.removeEventListener('pagehide', flush);
}, []);
const deleteProject = (id) => {
const loaded = loadProjectById(id);
if (!loaded) return;
deleteProjectFromStorage(id);
clearSyncBase(id);
setProjectIndex(loadProjectIndex());
pendingDeletes.current.set(id, window.setTimeout(() => finishDelete(id), 8000));
toast(`Deleted “${loaded.title}”`, {
action: {
label: 'Undo',
onClick: () => {
window.clearTimeout(pendingDeletes.current.get(id));
pendingDeletes.current.delete(id);
saveProjectToStorage(loaded);
setProjectIndex(loadProjectIndex());
},
},
});
};
const pullLatestFromServer = async (id) => { const pullLatestFromServer = async (id) => {
const result = await loadRemoteProject(id); const result = await loadRemoteProject(id);
if (!result.ok) { if (!result.ok) {
alert('Could not pull latest version from server.'); toast('Could not pull the latest version from the server.', { tone: 'error' });
return; return;
} }
saveProjectToStorage(result.data); saveProjectToStorage(result.data);
setSyncBase(id, result.data.lastEdited);
setProjectIndex(loadProjectIndex()); setProjectIndex(loadProjectIndex());
setStaleLocalProjects((prev) => prev.filter((e) => e.id !== id)); setStaleLocalProjects((prev) => prev.filter((e) => e.id !== id));
}; };
+41 -3
View File
@@ -6,6 +6,8 @@
// shown, instead of being a fire-and-forget call buried inside setState. // shown, instead of being a fire-and-forget call buried inside setState.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getRemoteReadingProgress, saveRemoteReadingProgress } from '../syncService.js'; import { getRemoteReadingProgress, saveRemoteReadingProgress } from '../syncService.js';
import { useToast } from '../components/Toasts.jsx';
import { mergeReadingPlans } from '../utils/backup.js';
import { clampPlanDays, localDateString, normalizeCustomPlan, registerCustomPlans } from '../utils/readingPlans.js'; import { clampPlanDays, localDateString, normalizeCustomPlan, registerCustomPlans } from '../utils/readingPlans.js';
const STORAGE_KEY = 'readingPlanProgress'; const STORAGE_KEY = 'readingPlanProgress';
@@ -33,7 +35,10 @@ function formatSyncError(result) {
} }
export function useReadingPlans() { export function useReadingPlans() {
const { toast } = useToast();
const [state, setState] = useState(loadLocal); const [state, setState] = useState(loadLocal);
const stateRef = useRef(state); // the latest state, for taking a copy just before something is removed
stateRef.current = state;
const [syncStatus, setSyncStatus] = useState(''); // '' | 'syncing' | 'synced' | 'error' const [syncStatus, setSyncStatus] = useState(''); // '' | 'syncing' | 'synced' | 'error'
const [syncError, setSyncError] = useState(''); const [syncError, setSyncError] = useState('');
// The exact object loadLocal() produced at mount. Compared by reference (not // The exact object loadLocal() produced at mount. Compared by reference (not
@@ -167,17 +172,30 @@ export function useReadingPlans() {
updateProgress((prev) => prev.map((p) => (p.id === progressId ? { ...p, translation } : p))); updateProgress((prev) => prev.map((p) => (p.id === progressId ? { ...p, translation } : p)));
}, [updateProgress]); }, [updateProgress]);
// Restarting and removing are immediate, with an "Undo" toast that puts the plan back exactly as it was.
const restartPlan = useCallback((progressId) => { const restartPlan = useCallback((progressId) => {
const before = stateRef.current.progress.find((p) => p.id === progressId);
updateProgress((prev) => prev.map((p) => ( updateProgress((prev) => prev.map((p) => (
p.id === progressId p.id === progressId
? { ...p, completedDays: [], chaptersRead: {}, completedAt: null, startedAt: Date.now(), lastActivityAt: Date.now() } ? { ...p, completedDays: [], chaptersRead: {}, completedAt: null, startedAt: Date.now(), lastActivityAt: Date.now() }
: p : p
))); )));
}, [updateProgress]); if (before) toast('Plan restarted from day 1', { action: { label: 'Undo', onClick: () => updateProgress((prev) => prev.map((p) => (p.id === progressId ? before : p))) } });
}, [updateProgress, toast]);
const removePlanProgress = useCallback((progressId) => { const removePlanProgress = useCallback((progressId) => {
const index = stateRef.current.progress.findIndex((p) => p.id === progressId);
const before = stateRef.current.progress[index];
updateProgress((prev) => prev.filter((p) => p.id !== progressId)); updateProgress((prev) => prev.filter((p) => p.id !== progressId));
}, [updateProgress]); if (before) {
toast('Plan removed', {
action: {
label: 'Undo',
onClick: () => updateProgress((prev) => (prev.some((p) => p.id === progressId) ? prev : [...prev.slice(0, index), before, ...prev.slice(index)])),
},
});
}
}, [updateProgress, toast]);
// Resets the pace clock so a plan that's fallen behind schedule stops being // Resets the pace clock so a plan that's fallen behind schedule stops being
// flagged as "behind" — doesn't touch which days are actually marked done. // flagged as "behind" — doesn't touch which days are actually marked done.
@@ -212,9 +230,22 @@ export function useReadingPlans() {
}, [updateCustomPlans]); }, [updateCustomPlans]);
const removeCustomPlan = useCallback((planId) => { const removeCustomPlan = useCallback((planId) => {
const plan = stateRef.current.customPlans.find((p) => p.id === planId);
const runs = stateRef.current.progress.filter((p) => p.planId === planId);
updateCustomPlans((prev) => prev.filter((p) => p.id !== planId)); updateCustomPlans((prev) => prev.filter((p) => p.id !== planId));
updateProgress((prev) => prev.filter((p) => p.planId !== planId)); updateProgress((prev) => prev.filter((p) => p.planId !== planId));
}, [updateCustomPlans, updateProgress]); if (plan) {
toast(`Deleted “${plan.title}”`, {
action: {
label: 'Undo',
onClick: () => {
updateCustomPlans((prev) => (prev.some((p) => p.id === planId) ? prev : [plan, ...prev]));
updateProgress((prev) => [...runs.filter((r) => !prev.some((p) => p.id === r.id)), ...prev]);
},
},
});
}
}, [updateCustomPlans, updateProgress, toast]);
// Patches an existing custom plan. Pass resetProgress: true when the edit // Patches an existing custom plan. Pass resetProgress: true when the edit
// changes its books/days (day indices from before the edit no longer mean // changes its books/days (day indices from before the edit no longer mean
@@ -230,6 +261,11 @@ export function useReadingPlans() {
} }
}, [updateCustomPlans, updateProgress]); }, [updateCustomPlans, updateProgress]);
// Plans from a backup file are added to what is here (nothing is removed).
const importReadingPlans = useCallback((incoming) => {
setState((prev) => ({ ...prev, ...mergeReadingPlans(prev, incoming) }));
}, []);
// Server wins over whatever's local at sign-in. // Server wins over whatever's local at sign-in.
const mergeServerReadingProgress = useCallback((serverData) => { const mergeServerReadingProgress = useCallback((serverData) => {
if (!serverData || typeof serverData !== 'object' || Array.isArray(serverData)) { if (!serverData || typeof serverData !== 'object' || Array.isArray(serverData)) {
@@ -280,6 +316,8 @@ export function useReadingPlans() {
}, [state]); }, [state]);
return { return {
readingPlansState: state,
importReadingPlans,
planProgress: state.progress, planProgress: state.progress,
customPlans, customPlans,
readingActivity: state.activity, readingActivity: state.activity,
+40
View File
@@ -49,3 +49,43 @@ 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;
}
}
/* Words that differ between two translations (Reader → read beside another Bible) */
.diff-hl {
background: color-mix(in srgb, var(--r-accent, #a84e27) 20%, transparent);
border-bottom: 2px solid color-mix(in srgb, var(--r-accent, #a84e27) 55%, transparent);
border-radius: 2px;
}
+145 -38
View File
@@ -1,8 +1,91 @@
import { useState } from 'react'; import { useState } from 'react';
import { useApp } from '../context/AppContext.js'; import { useApp } from '../context/AppContext.js';
import { themeVars } from '../components/reader/readerThemes.js'; import { themeVars, READER_THEMES } from '../components/reader/readerThemes.js';
import { loginUser, registerUser, verifyMfaLogin, logoutUser } from '../syncService.js'; import { loginUser, registerUser, verifyMfaLogin, logoutUser } from '../syncService.js';
// What the app does, in four groups. Everything listed here exists in the app.
const FEATURES = [
{
title: 'Read',
points: [
['Six translations', 'BSB, ESV, NLT, CSB, NASB and NET, with audio and whole-Bible search.'],
['Compare them', 'Read two side by side and see which words differ, verse by verse.'],
['Mark it up', 'Highlight in colours you name, bookmark, and draw notes on the page.'],
['Reading plans', 'Daily readings that open straight into the text, with streaks and reminders.'],
],
},
{
title: 'Study',
points: [
['Chunk a passage', 'Break it into sections and write Observe, Interpret and Apply notes.'],
['Original languages', 'Greek and Hebrew word studies with pronunciation, from the interlinear.'],
['Beside the text', 'Commentary, cross-references, and people and places for each chunk.'],
],
},
{
title: 'Teach',
points: [
['A real outline', 'Main points and sub-points for the whole study and for each chunk, with notes, verse text and minutes.'],
['Scripts', 'Turn your notes into a script for a lesson, episode or sermon.'],
['Share it', 'Word, PDF and Markdown files, or a read-only link anyone can open.'],
],
},
{
title: 'Everywhere',
points: [
['Your devices', 'Studies, marks and plans sync to your account, and nothing is overwritten silently.'],
['Offline and backup', 'Save books to read without a connection, and download everything as one file.'],
['Private', 'Your studies stay in your account, with optional two-factor sign-in.'],
],
},
];
// Small, decorative pictures of two real views, so you can see what you're signing in to.
function OutlinePreview() {
const row = (label, text, depth = 0, tag = '') => (
<div className="flex items-baseline gap-2" style={{ paddingLeft: `${depth * 1.1}rem` }}>
<span className="w-5 shrink-0 text-right font-semibold text-[var(--r-accent)]">{label}</span>
<span className={depth === 0 ? 'font-semibold' : ''}>
{tag && <span className="mr-1.5 rounded-full border border-[var(--r-line)] px-1.5 py-px font-sans text-[8px] font-semibold uppercase tracking-widest text-[var(--r-accent)]">{tag}</span>}
{text}
</span>
</div>
);
return (
<div aria-hidden="true" className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-4 font-serif text-[13px] leading-relaxed shadow-sm">
<p className="mb-2 font-sans text-[9px] font-semibold uppercase tracking-[0.18em] text-[var(--r-accent)]">Outline</p>
{row('I.', 'Greeting and the gospel hope')}
{row('A.', 'Servant of God', 1, 'Illustration')}
{row('B.', 'Apostle of Jesus Christ', 1)}
{row('II.', 'Appoint elders in every town')}
{row('A.', 'Blameless and faithful', 1, 'Application')}
</div>
);
}
function ComparePreview() {
const mark = (t) => <span className="diff-hl">{t}</span>;
return (
<div aria-hidden="true" className="grid grid-cols-2 gap-3 rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-4 font-serif text-[13px] leading-relaxed shadow-sm">
<div><p className="mb-1 font-sans text-[9px] font-semibold uppercase tracking-[0.18em] text-[var(--r-sub)]">BSB</p>The earth was {mark('formless and void')}, and darkness was over the surface of the deep.</div>
<div><p className="mb-1 font-sans text-[9px] font-semibold uppercase tracking-[0.18em] text-[var(--r-sub)]">NET</p>The earth was {mark('without shape and empty')}, and darkness was over the surface of the deep.</div>
</div>
);
}
function ThemeSwitch() {
const { readerTheme, setReaderTheme } = useApp();
if (!setReaderTheme) return null;
return (
<div role="group" aria-label="Theme" className="flex overflow-hidden rounded-full border border-[var(--r-line)] text-xs">
{Object.entries(READER_THEMES).map(([id, t]) => (
<button key={id} type="button" aria-pressed={readerTheme === id} onClick={() => setReaderTheme(id)}
className={`px-3 py-1.5 ${readerTheme === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{t.label}</button>
))}
</div>
);
}
export default function AuthPage() { export default function AuthPage() {
const { signIn, readerTheme } = useApp(); const { signIn, readerTheme } = useApp();
const shell = { style: themeVars(readerTheme) }; const shell = { style: themeVars(readerTheme) };
@@ -53,8 +136,8 @@ export default function AuthPage() {
return ( return (
<div {...shell} className="flex min-h-dvh items-center justify-center bg-[var(--r-bg)] px-4 text-[var(--r-text)]"> <div {...shell} className="flex min-h-dvh items-center justify-center bg-[var(--r-bg)] px-4 text-[var(--r-text)]">
<div className="w-full max-w-sm rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-8"> <div className="w-full max-w-sm rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-8">
<p className="text-sm uppercase tracking-[0.24em] text-[var(--r-sub)]">Bible Study Project</p> <p className="flex items-center gap-2 text-sm uppercase tracking-[0.24em] text-[var(--r-sub)]"><img src="/logo.svg" alt="" className="h-6 w-6" />Bible Study Project</p>
<h1 className="mt-2 text-xl font-medium text-[var(--r-text)]">Two-factor verification</h1> <h1 className="mt-3 text-xl font-medium text-[var(--r-text)]">Two-factor verification</h1>
<p className="mt-2 text-sm text-[var(--r-sub)]"> <p className="mt-2 text-sm text-[var(--r-sub)]">
{authMfaUseBackup {authMfaUseBackup
? 'Enter one of your saved backup codes.' ? 'Enter one of your saved backup codes.'
@@ -109,39 +192,24 @@ export default function AuthPage() {
} }
return ( return (
<div {...shell} className="flex min-h-dvh items-center justify-center bg-[var(--r-bg)] px-4 py-12 text-[var(--r-text)]"> <div {...shell} className="min-h-dvh bg-[var(--r-bg)] text-[var(--r-text)]">
<div className="grid w-full max-w-5xl items-center gap-16 lg:grid-cols-2"> <div className="mx-auto flex max-w-6xl items-center justify-between px-5 pt-5 sm:px-8">
{/* Branding / feature panel — hidden on small screens to keep the form front and center there */} <p className="flex items-center gap-2.5 text-sm font-medium">
<div className="hidden lg:block"> <img src="/logo.svg" alt="" className="h-7 w-7" />
<p className="text-sm uppercase tracking-[0.24em] text-[var(--r-sub)]">Bible Study Project</p> <span>Bible Study Project</span>
<h1 className="mt-4 text-3xl font-semibold leading-tight text-[var(--r-text)]">
Deep Bible study,<br />one chunk at a time.
</h1>
<p className="mt-4 max-w-sm text-[var(--r-sub)]">
Read Scripture in six translations, follow a reading plan into the text, and study a passage one chunk
at a time with commentary, word studies, and cross-references beside it.
</p> </p>
<ul className="mt-8 space-y-3 text-sm text-[var(--r-sub)]"> <ThemeSwitch />
<li>Read in BSB, ESV, NLT, CSB, NASB, or NET, with audio and search</li>
<li>Reading plans that open straight into the Reader</li>
<li>Observe, Interpret, Apply notes with Greek &amp; Hebrew word studies</li>
<li>Turn your notes into a script, then export or share</li>
<li>Your studies stay private to your account, with optional 2FA</li>
</ul>
<blockquote className="mt-8 border-l-2 border-[var(--r-line)] pl-4 text-sm italic text-[var(--r-sub)]">
"Make every effort to present yourself approved to God, an unashamed workman who accurately
handles the word of truth."
<footer className="mt-1 not-italic text-[var(--r-sub)]">— 2 Timothy 2:15 (BSB)</footer>
</blockquote>
</div> </div>
{/* Sign in / register form */} <div className="mx-auto grid max-w-6xl gap-x-14 gap-y-12 px-5 pb-16 pt-8 sm:px-8 lg:grid-cols-[minmax(0,1fr)_24rem] lg:pt-14">
<div className="flex justify-center lg:justify-start"> {/* Sign in / create account. First on a phone; on a wide screen it stays in view beside the tour. */}
<div className="w-full max-w-sm rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-8"> <div className="lg:order-2">
<p className="text-sm uppercase tracking-[0.24em] text-[var(--r-sub)] lg:hidden">Bible Study Project</p> <div className="lg:sticky lg:top-10">
<h1 className="mt-2 text-xl font-medium text-[var(--r-text)]"> <div className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-7 shadow-sm sm:p-8">
{authMode === 'login' ? 'Sign in' : 'Create an account'} <h2 className="text-xl font-medium">{authMode === 'login' ? 'Sign in' : 'Create an account'}</h2>
</h1> <p className="mt-1 text-sm text-[var(--r-sub)]">
{authMode === 'login' ? 'Pick up where you left off.' : 'Free to start. Your studies stay in your account.'}
</p>
<form onSubmit={submitAuth} className="mt-6 space-y-4"> <form onSubmit={submitAuth} className="mt-6 space-y-4">
<label className="block text-sm"> <label className="block text-sm">
Email Email
@@ -151,10 +219,10 @@ export default function AuthPage() {
autoComplete="email" autoComplete="email"
value={authForm.email} value={authForm.email}
onChange={(e) => setAuthForm((f) => ({ ...f, email: e.target.value }))} onChange={(e) => setAuthForm((f) => ({ ...f, email: e.target.value }))}
className="mt-1 block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-[var(--r-text)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" className="mt-1 block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2.5 text-sm text-[var(--r-text)]"
/> />
</label> </label>
<label className="block text-sm font-medium text-[var(--r-text)]"> <label className="block text-sm">
Password Password
<input <input
type="password" type="password"
@@ -163,10 +231,11 @@ export default function AuthPage() {
autoComplete={authMode === 'login' ? 'current-password' : 'new-password'} autoComplete={authMode === 'login' ? 'current-password' : 'new-password'}
value={authForm.password} value={authForm.password}
onChange={(e) => setAuthForm((f) => ({ ...f, password: e.target.value }))} onChange={(e) => setAuthForm((f) => ({ ...f, password: e.target.value }))}
className="mt-1 block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-[var(--r-text)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" className="mt-1 block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2.5 text-sm text-[var(--r-text)]"
/> />
{authMode === 'register' && <span className="mt-1 block text-xs text-[var(--r-sub)]">At least 8 characters.</span>}
</label> </label>
{authError && <p className="text-sm text-[var(--r-danger,#b42318)]">{authError}</p>} {authError && <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{authError}</p>}
<button <button
type="submit" type="submit"
disabled={authBusy} disabled={authBusy}
@@ -183,9 +252,47 @@ export default function AuthPage() {
}} }}
className="mt-4 w-full text-center text-sm text-[var(--r-sub)] underline hover:text-[var(--r-text)]" className="mt-4 w-full text-center text-sm text-[var(--r-sub)] underline hover:text-[var(--r-text)]"
> >
{authMode === 'login' ? "Need an account? Sign up" : 'Already have an account? Sign in'} {authMode === 'login' ? 'Need an account? Sign up' : 'Already have an account? Sign in'}
</button> </button>
</div> </div>
<blockquote className="mt-6 hidden border-l-2 border-[var(--r-line)] pl-4 text-sm italic leading-relaxed text-[var(--r-sub)] lg:block">
“Make every effort to present yourself approved to God, an unashamed workman who accurately handles the word of truth.”
<footer className="mt-1 not-italic">— 2 Timothy 2:15 (BSB)</footer>
</blockquote>
</div>
</div>
{/* What the app does */}
<div className="lg:order-1">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--r-accent)]">Bible study, start to finish</p>
<h1 className="mt-3 text-4xl font-semibold leading-[1.1] tracking-tight sm:text-5xl">
Read it. Study it.<br />Teach it.
</h1>
<p className="mt-5 max-w-xl text-base leading-relaxed text-[var(--r-sub)]">
A place to read Scripture in six translations, work through a passage one chunk at a time, and turn what you learn into an outline,
a script, and something you can share.
</p>
<div className="mt-8 grid gap-4 sm:grid-cols-2">
<OutlinePreview />
<ComparePreview />
</div>
<div className="mt-12 grid gap-x-10 gap-y-9 sm:grid-cols-2">
{FEATURES.map((group) => (
<section key={group.title} aria-label={group.title}>
<h2 className="border-b border-[var(--r-line)] pb-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--r-accent)]">{group.title}</h2>
<ul className="mt-3 space-y-3.5">
{group.points.map(([name, text]) => (
<li key={name}>
<p className="text-[15px] font-medium">{name}</p>
<p className="mt-0.5 text-sm leading-relaxed text-[var(--r-sub)]">{text}</p>
</li>
))}
</ul>
</section>
))}
</div>
</div> </div>
</div> </div>
</div> </div>
+6 -3
View File
@@ -9,6 +9,7 @@ import {
loadProjectIndex, loadProjectIndex,
} from '../utils/bibleUtils.jsx'; } from '../utils/bibleUtils.jsx';
import { loadProjectById } from '../utils/projectStorage.js'; import { loadProjectById } from '../utils/projectStorage.js';
import { compactPassages } from '../utils/passageSummary.js';
import { getPlan, getPlanSchedule, formatDayReadings, nextUnreadDayIndex, computeStreak } from '../utils/readingPlans.js'; import { getPlan, getPlanSchedule, formatDayReadings, nextUnreadDayIndex, computeStreak } from '../utils/readingPlans.js';
export default function HomePage() { export default function HomePage() {
@@ -215,10 +216,12 @@ export default function HomePage() {
<button type="button" onClick={() => { setRenamingId(entry.id); setRenameValue(entry.title ?? ''); }} className="shrink-0 text-xs text-[var(--r-sub)] hover:text-[var(--r-text)]" title="Rename project">✎ Rename</button> <button type="button" onClick={() => { setRenamingId(entry.id); setRenameValue(entry.title ?? ''); }} className="shrink-0 text-xs text-[var(--r-sub)] hover:text-[var(--r-text)]" title="Rename project">✎ Rename</button>
</div> </div>
)} )}
{entry.chapterSummary && <p className="mt-1 text-sm text-[var(--r-sub)]">{entry.chapterSummary}</p>} {entry.chapterSummary && <p className="mt-1 text-sm text-[var(--r-sub)]" title={entry.chapterSummary}>{compactPassages(entry.chapterSummary)}</p>}
{entry.tags?.length > 0 && ( {entry.tags?.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1"> // A few tags and a "+N" for the rest (hover for all; the Tag filter above finds any of them)
{entry.tags.map((tag) => <span key={tag} className="rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[11px] text-[var(--r-sub)]">{tag}</span>)} <div className="mt-2 flex flex-wrap gap-1" title={entry.tags.length > 3 ? entry.tags.join(', ') : undefined}>
{entry.tags.slice(0, 3).map((tag) => <span key={tag} className="rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[11px] text-[var(--r-sub)]">{tag}</span>)}
{entry.tags.length > 3 && <span className="rounded-full px-1.5 py-0.5 text-[11px] text-[var(--r-sub)]">+{entry.tags.length - 3} more</span>}
</div> </div>
)} )}
<p className="mt-1 text-xs text-[var(--r-sub)]">{formatRelativeDate(entry.lastEdited)}</p> <p className="mt-1 text-xs text-[var(--r-sub)]">{formatRelativeDate(entry.lastEdited)}</p>
+333
View File
@@ -0,0 +1,333 @@
// 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 ShareOutlineSheet from '../components/ShareOutlineSheet.jsx';
import { downloadOutlineDocx, downloadOutlineMarkdown, printOutline } 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 [shareOpen, setShareOpen] = useState(false);
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 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={() => setShareOpen(true)} 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>
<ShareOutlineSheet open={shareOpen} onClose={() => setShareOpen(false)} projectId={project.id} outline={outline} />
</StudioShell>
);
}
+105 -12
View File
@@ -5,7 +5,9 @@ import { useApp } from '../context/AppContext.js';
import { bookOptions } from '../utils/bibleUtils.jsx'; import { bookOptions } from '../utils/bibleUtils.jsx';
import { getPlan, getPlanSchedule, findDayIndexForRef, formatDayReadings, nextUnreadDayIndex, computeStreak, nearestReadingDay } from '../utils/readingPlans.js'; import { getPlan, getPlanSchedule, findDayIndexForRef, formatDayReadings, nextUnreadDayIndex, computeStreak, nearestReadingDay } from '../utils/readingPlans.js';
import DrawCanvas from './DrawCanvas.jsx'; import DrawCanvas from './DrawCanvas.jsx';
import { READER_TRANSLATIONS } from '../hooks/useBibleReader.js'; import { READER_TRANSLATIONS } from '../utils/bibleText.js';
import { useParallelChapter } from '../hooks/useParallelChapter.js';
import { useOfflineSave, useOnline } from '../hooks/useOfflineSave.js';
import { useBibleSearch } from '../hooks/useBibleSearch.js'; import { useBibleSearch } from '../hooks/useBibleSearch.js';
import { themeVars } from '../components/reader/readerThemes.js'; import { themeVars } from '../components/reader/readerThemes.js';
import { TopBar, BottomBar } from '../components/reader/ReaderBars.jsx'; import { TopBar, BottomBar } from '../components/reader/ReaderBars.jsx';
@@ -13,8 +15,11 @@ import ReaderText from '../components/reader/ReaderText.jsx';
import PlanStrip from '../components/reader/PlanStrip.jsx'; 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 CompareSheet from '../components/reader/CompareSheet.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 +27,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 +44,11 @@ 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,
readerShowDiff, setReaderShowDiff,
readerBookmarks, readerBookmarks,
readerCrossRefs, setReaderCrossRefs, readerCrossRefs, setReaderCrossRefs,
readerCrossRefsLoading, readerCrossRefsLoading,
@@ -56,6 +65,7 @@ export default function ReaderPage() {
readerGoToNextChapter, readerGoToNextChapter,
jumpToReaderVerse, jumpToReaderVerse,
toggleReaderBookmark, toggleReaderBookmark,
restoreReaderBookmark,
cycleBookmarkColor, cycleBookmarkColor,
loadReaderCrossRefs, loadReaderCrossRefs,
loadBibleIndex, loadBibleIndex,
@@ -69,6 +79,8 @@ export default function ReaderPage() {
readerTextHighlights, readerTextHighlights,
addTextHighlight, addTextHighlight,
removeTextHighlight, removeTextHighlight,
restoreTextHighlight,
readerColorLabels, setReaderColorLabel,
planProgress, planProgress,
toggleDay, toggleDay,
toggleChapterRead, toggleChapterRead,
@@ -77,10 +89,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 +223,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 +264,21 @@ 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 === 'c' || e.key === 'C') { e.preventDefault(); openSheet('compare'); }
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,10 +292,22 @@ 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, diffRanges) {
const spans = []; const spans = [];
for (const r of diffRanges ?? []) spans.push({ start: r.start, end: r.end, type: 'diff' });
for (const h of (readerTextHighlights || [])) { for (const h of (readerTextHighlights || [])) {
if (h.book === readerBookAbbrev && h.chapter === readerChapter && h.verse === verseNum) { if (h.book === readerBookAbbrev && h.chapter === readerChapter && h.verse === verseNum) {
spans.push({ start: h.startOffset, end: h.endOffset, type: 'hl', id: h.id, color: h.color }); spans.push({ start: h.startOffset, end: h.endOffset, type: 'hl', id: h.id, color: h.color });
@@ -267,6 +322,11 @@ export default function ReaderPage() {
} }
} }
if (!spans.length) return text; if (!spans.length) return text;
// Your highlights and search matches always show; a difference mark that would overlap one is dropped
const firm = spans.filter((x) => x.type !== 'diff');
for (let i = spans.length - 1; i >= 0; i -= 1) {
if (spans[i].type === 'diff' && firm.some((f) => spans[i].start < f.end && f.start < spans[i].end)) spans.splice(i, 1);
}
spans.sort((a, b) => a.start - b.start); spans.sort((a, b) => a.start - b.start);
const parts = []; const parts = [];
let cursor = 0; let cursor = 0;
@@ -276,10 +336,13 @@ 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>,
); );
} else if (s.type === 'diff') {
parts.push(<span key={`diff${s.start}`} className="diff-hl">{text.slice(s.start, s.end)}</span>);
} else { } else {
parts.push(<mark key={`srch${s.start}`} className="rounded bg-[var(--r-hl)] px-0.5 text-[var(--r-text)]">{text.slice(s.start, s.end)}</mark>); parts.push(<mark key={`srch${s.start}`} className="rounded bg-[var(--r-hl)] px-0.5 text-[var(--r-text)]">{text.slice(s.start, s.end)}</mark>);
} }
@@ -307,7 +370,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 +380,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 +406,8 @@ 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}
onCompare={() => openSheet('compare')} compareActive={!!parallelId}
onSettings={() => openSheet('settings')} onSettings={() => openSheet('settings')}
onDoneDrawing={readerDrawMode ? () => setReaderDrawMode(false) : undefined} onDoneDrawing={readerDrawMode ? () => setReaderDrawMode(false) : undefined}
/> />
@@ -359,11 +429,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} showDiff={readerShowDiff}
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 +524,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 +550,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 +558,14 @@ 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}
/>
<CompareSheet
open={sheet === 'compare'} onClose={closeSheet}
translation={readerTranslation} parallel={parallelId} onParallel={setReaderParallel}
showDiff={readerShowDiff} onShowDiff={setReaderShowDiff}
/> />
<SearchSheet <SearchSheet
open={sheet === 'search'} onClose={closeSheet} open={sheet === 'search'} onClose={closeSheet}
@@ -490,9 +577,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 +605,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 +614,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);
+11
View File
@@ -47,6 +47,17 @@ test('renders the chapter, chrome and opens the tool sheets', () => {
expect(screen.getByPlaceholderText(/Search John 3/)).toBeTruthy(); expect(screen.getByPlaceholderText(/Search John 3/)).toBeTruthy();
}); });
test('the compare button next to Aa opens the comparison sheet, and choosing a Bible turns it on', () => {
ctx.setReaderParallel = vi.fn();
render(<ReaderPage />);
const compare = screen.getByLabelText('Compare translations');
expect(compare.nextElementSibling).toBe(screen.getByLabelText('Reading settings')); // sits right beside Aa
fireEvent.click(compare);
expect(screen.getByText(/how different its wording is/)).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'NET' }));
expect(ctx.setReaderParallel).toHaveBeenCalledWith('NET');
});
test('tapping a verse selects it', () => { test('tapping a verse selects it', () => {
render(<ReaderPage />); render(<ReaderPage />);
fireEvent.click(screen.getByText(/This man came to Jesus/)); fireEvent.click(screen.getByText(/This man came to Jesus/));
+9 -20
View File
@@ -3,8 +3,7 @@
// the custom-plan builder and everything else open as sheets. // the custom-plan builder and everything else open as sheets.
import { useState } from 'react'; import { useState } from 'react';
import { useApp } from '../context/AppContext.js'; import { useApp } from '../context/AppContext.js';
import { themeVars, READER_THEMES } from '../components/reader/readerThemes.js'; import PageShell from '../components/study/PageShell.jsx';
import { ChevronLeft } from '../components/reader/icons.jsx';
import Sheet from '../components/reader/Sheet.jsx'; import Sheet from '../components/reader/Sheet.jsx';
import PlanDetailSheet from '../components/plans/PlanDetailSheet.jsx'; import PlanDetailSheet from '../components/plans/PlanDetailSheet.jsx';
import CustomPlanBuilder from '../components/plans/CustomPlanBuilder.jsx'; import CustomPlanBuilder from '../components/plans/CustomPlanBuilder.jsx';
@@ -14,7 +13,6 @@ import {
} from '../utils/readingPlans.js'; } from '../utils/readingPlans.js';
const CATEGORY_ORDER = ['Whole Bible', 'Old Testament', 'New Testament', 'Topical']; const CATEGORY_ORDER = ['Whole Bible', 'Old Testament', 'New Testament', 'Topical'];
const THEME_ORDER = ['paper', 'sepia', 'dark'];
const pill = 'rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[10px] text-[var(--r-sub)]'; const pill = 'rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[10px] text-[var(--r-sub)]';
const sectionTitle = 'mb-2 mt-7 text-[11px] uppercase tracking-wide text-[var(--r-sub)]'; const sectionTitle = 'mb-2 mt-7 text-[11px] uppercase tracking-wide text-[var(--r-sub)]';
@@ -34,13 +32,12 @@ function nextOf(progress) {
export default function ReadingPlansPage() { export default function ReadingPlansPage() {
const { const {
goHome,
planProgress, customPlans, readingActivity, planProgress, customPlans, readingActivity,
startPlan, toggleDay, restartPlan, removePlanProgress, recalibratePace, setPlanTranslation, startPlan, toggleDay, restartPlan, removePlanProgress, recalibratePace, setPlanTranslation,
addCustomPlan, removeCustomPlan, updateCustomPlan, addCustomPlan, removeCustomPlan, updateCustomPlan,
openPlanReading, studyReadingPlanDay, studyBuildBusy, openPlanReading, studyReadingPlanDay, studyBuildBusy,
readingPlanSyncStatus, readingPlanSyncError, retryReadingPlanSync, readingPlanSyncStatus, readingPlanSyncError, retryReadingPlanSync,
readerTheme, setReaderTheme, readerTranslation, planTranslationPref, readerTranslation, planTranslationPref,
} = useApp(); } = useApp();
const [detail, setDetail] = useState(null); // { planId, progressId? } const [detail, setDetail] = useState(null); // { planId, progressId? }
@@ -59,8 +56,6 @@ export default function ReadingPlansPage() {
const categories = ['All', ...CATEGORY_ORDER.filter((c) => READING_PLANS.some((p) => p.category === c)), ...(customPlans.length ? ['My Plans'] : [])]; const categories = ['All', ...CATEGORY_ORDER.filter((c) => READING_PLANS.some((p) => p.category === c)), ...(customPlans.length ? ['My Plans'] : [])];
const shown = catalog.filter((p) => category === 'All' || p.category === category); const shown = catalog.filter((p) => category === 'All' || p.category === category);
const cycleTheme = () => setReaderTheme(THEME_ORDER[(THEME_ORDER.indexOf(readerTheme) + 1) % THEME_ORDER.length]);
const actions = { const actions = {
defaultTranslation: planTranslationPref && planTranslationPref !== 'same' ? planTranslationPref : readerTranslation, defaultTranslation: planTranslationPref && planTranslationPref !== 'same' ? planTranslationPref : readerTranslation,
setTranslation: setPlanTranslation, setTranslation: setPlanTranslation,
@@ -100,17 +95,11 @@ export default function ReadingPlansPage() {
}; };
return ( return (
<div className="min-h-dvh bg-[var(--r-bg)] pb-16 text-[var(--r-text)]" style={themeVars(readerTheme)}> <PageShell
<div className="safe-top sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)] font-sans"> title="Reading Plans" active="plans" maxWidth="max-w-3xl"
<div className="mx-auto flex h-12 max-w-3xl items-center gap-1 px-2"> actions={streak.current > 0 ? <span className="text-xs text-[var(--r-accent)]">{streak.current} day streak</span> : null}
<button type="button" onClick={goHome} aria-label="Back to home" className="flex h-9 items-center gap-0.5 rounded-lg pl-1 pr-2 text-[var(--r-sub)] hover:bg-black/5"><ChevronLeft /><span className="hidden text-xs sm:inline">Home</span></button> >
<h1 className="mx-auto text-sm font-medium">Reading plans</h1> <main className="mx-auto max-w-3xl px-4 pb-16 pt-5 font-sans">
{streak.current > 0 && <span className="text-xs text-[var(--r-accent)]">{streak.current} day streak</span>}
<button type="button" onClick={cycleTheme} className="ml-2 rounded-lg px-2 py-1.5 text-xs text-[var(--r-sub)] hover:bg-black/5" title="Change theme">{READER_THEMES[readerTheme]?.label}</button>
</div>
</div>
<main className="mx-auto max-w-3xl px-4 pt-5 font-sans">
{readingPlanSyncStatus === 'error' && ( {readingPlanSyncStatus === 'error' && (
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-red-300 px-4 py-3 text-sm"> <div className="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-red-300 px-4 py-3 text-sm">
<span className="text-[var(--r-danger,#b42318)]"> <span className="text-[var(--r-danger,#b42318)]">
@@ -217,7 +206,7 @@ export default function ReadingPlansPage() {
</div> </div>
<div className="flex shrink-0 items-center gap-3 text-xs"> <div className="flex shrink-0 items-center gap-3 text-xs">
<button type="button" onClick={() => restartPlan(p.id)} className="font-medium hover:underline">Start again</button> <button type="button" onClick={() => restartPlan(p.id)} className="font-medium hover:underline">Start again</button>
<button type="button" onClick={() => { if (window.confirm('Remove this from your history?')) removePlanProgress(p.id); }} className="text-[var(--r-danger,#b42318)] hover:underline">Remove</button> <button type="button" onClick={() => removePlanProgress(p.id)} className="text-[var(--r-danger,#b42318)] hover:underline">Remove</button>
</div> </div>
</div> </div>
); );
@@ -240,6 +229,6 @@ export default function ReadingPlansPage() {
<CustomPlanBuilder key={builderMode} initialValues={editingPlan} onSubmit={submitBuilder} onCancel={() => setBuilderMode(null)} /> <CustomPlanBuilder key={builderMode} initialValues={editingPlan} onSubmit={submitBuilder} onCancel={() => setBuilderMode(null)} />
)} )}
</Sheet> </Sheet>
</div> </PageShell>
); );
} }
+5 -1
View File
@@ -3,6 +3,7 @@
// pronunciation guide live here because this is where they're needed. // pronunciation guide live here because this is where they're needed.
import { useState } from 'react'; import { useState } from 'react';
import { useApp } from '../context/AppContext.js'; import { useApp } from '../context/AppContext.js';
import { useToast } from '../components/Toasts.jsx';
import StudioShell from '../components/study/StudioShell.jsx'; import StudioShell from '../components/study/StudioShell.jsx';
import { formatChunkReference } from '../utils/bibleUtils.jsx'; import { formatChunkReference } from '../utils/bibleUtils.jsx';
import { buildClaudePrompt, buildPodcastPrompt } from '../utils/exportBuilders.js'; import { buildClaudePrompt, buildPodcastPrompt } from '../utils/exportBuilders.js';
@@ -15,6 +16,7 @@ const btn = 'rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:
export default function ScriptPage() { export default function ScriptPage() {
const { project, allChunks, updateProject, updateChunk, authUser, setSaveStatus, setCurrentPage } = useApp(); const { project, allChunks, updateProject, updateChunk, authUser, setSaveStatus, setCurrentPage } = useApp();
const [notice, setNotice] = useState(''); const [notice, setNotice] = useState('');
const { toast } = useToast();
const chunk = allChunks.find((c) => c.id === project?.selectedChunkId) ?? allChunks[0] ?? null; const chunk = allChunks.find((c) => c.id === project?.selectedChunkId) ?? allChunks[0] ?? null;
const chapterIndex = chunk ? project.chapters.findIndex((ch) => ch.chunks.some((c) => c.id === chunk.id)) : -1; const chapterIndex = chunk ? project.chapters.findIndex((ch) => ch.chunks.some((c) => c.id === chunk.id)) : -1;
@@ -26,9 +28,11 @@ export default function ScriptPage() {
const set = (patch) => updateChunk(chunk.id, patch); const set = (patch) => updateChunk(chunk.id, patch);
const append = (text) => set({ finalScript: script.trim() ? `${script.replace(/\s+$/, '')}\n\n${text}` : text }); const append = (text) => set({ finalScript: script.trim() ? `${script.replace(/\s+$/, '')}\n\n${text}` : text });
// Replaces the script with an outline from your notes — and says how to get the old one back.
const draft = () => { const draft = () => {
if (has(script) && !window.confirm('Replace the current script with a fresh outline from your notes?')) return; const before = script;
set({ finalScript: buildScriptDraft(chunk, reference) }); set({ finalScript: buildScriptDraft(chunk, reference) });
if (has(before)) toast('Script replaced with a fresh outline', { action: { label: 'Undo', onClick: () => set({ finalScript: before }) } });
}; };
const importDocx = async (file) => { const importDocx = async (file) => {
+13 -2
View File
@@ -1,7 +1,8 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useApp } from '../context/AppContext.js'; import { useApp } from '../context/AppContext.js';
import PageShell from '../components/study/PageShell.jsx'; import PageShell from '../components/study/PageShell.jsx';
import { READER_TRANSLATIONS } from '../hooks/useBibleReader.js'; import { READER_TRANSLATIONS } from '../utils/bibleText.js';
import DataSection from '../components/settings/DataSection.jsx';
import { COMMENTARIES } from '../hooks/useVerseStudy.js'; import { COMMENTARIES } from '../hooks/useVerseStudy.js';
import { READER_THEMES } from '../components/reader/readerThemes.js'; import { READER_THEMES } from '../components/reader/readerThemes.js';
import { import {
@@ -55,7 +56,15 @@ export default function SettingsPage() {
readerWide, setReaderWide, readerWide, setReaderWide,
readerCommentary, setReaderCommentary, readerCommentary, setReaderCommentary,
audioNarrator, setAudioNarrator, audioNarrator, setAudioNarrator,
settingsFocus, clearSettingsFocus,
} = useApp(); } = useApp();
// "Back up my data" from the ⌘K palette lands on that section
useEffect(() => {
if (!settingsFocus) return;
document.getElementById(settingsFocus)?.scrollIntoView({ block: 'start' });
clearSettingsFocus();
}, [settingsFocus]); // eslint-disable-line react-hooks/exhaustive-deps
const [mfaSetup, setMfaSetup] = useState(null); // { secret, qrCodeDataUrl } while setup is in progress const [mfaSetup, setMfaSetup] = useState(null); // { secret, qrCodeDataUrl } while setup is in progress
const [mfaSetupCode, setMfaSetupCode] = useState(''); const [mfaSetupCode, setMfaSetupCode] = useState('');
const [mfaSetupError, setMfaSetupError] = useState(''); const [mfaSetupError, setMfaSetupError] = useState('');
@@ -341,6 +350,8 @@ export default function SettingsPage() {
{podcastNameSaved && <p className="text-sm text-[var(--r-accent)]">Saved.</p>} {podcastNameSaved && <p className="text-sm text-[var(--r-accent)]">Saved.</p>}
</section> </section>
<DataSection />
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-4"> <section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-4">
<div> <div>
<h2 className="text-base font-medium text-[var(--r-text)]">My reading journal</h2> <h2 className="text-base font-medium text-[var(--r-text)]">My reading journal</h2>
+26 -6
View File
@@ -10,18 +10,19 @@
const BASE = '/api'; const BASE = '/api';
async function request(method, path, body) { async function request(method, path, body, { keepalive = false, headers = {} } = {}) {
try { try {
const opts = { const opts = {
method, method,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json', ...headers },
credentials: 'include', credentials: 'include',
}; };
if (keepalive) opts.keepalive = true; // lets the request finish even as the tab closes
if (body !== undefined) opts.body = JSON.stringify(body); if (body !== undefined) opts.body = JSON.stringify(body);
const res = await fetch(`${BASE}${path}`, opts); const res = await fetch(`${BASE}${path}`, opts);
const data = await res.json().catch(() => null); const data = await res.json().catch(() => null);
if (!res.ok) { if (!res.ok) {
return { ok: false, status: res.status, error: data?.error ?? `HTTP ${res.status}` }; return { ok: false, status: res.status, error: data?.error ?? `HTTP ${res.status}`, data };
} }
return { ok: true, data }; return { ok: true, data };
} catch (err) { } catch (err) {
@@ -51,10 +52,14 @@ export async function loadRemoteProject(id) {
/** /**
* Save (create or update) a project on the server. * Save (create or update) a project on the server.
* `baseEdited` is the server copy's lastEdited this device last saw: if the server has moved on, the
* save is refused with { ok: false, status: 409, data: { serverEdited } }. `force` overwrites anyway.
* Returns { ok, data: summary } * Returns { ok, data: summary }
*/ */
export async function saveRemoteProject(project) { export async function saveRemoteProject(project, { baseEdited, force = false } = {}) {
return request('PUT', `/projects/${project.id}`, project); return request('PUT', `/projects/${project.id}${force ? '?force=1' : ''}`, project, {
headers: Number.isFinite(baseEdited) ? { 'X-Base-Edited': String(baseEdited) } : {},
});
} }
/** /**
@@ -62,7 +67,7 @@ export async function saveRemoteProject(project) {
* Returns { ok } * Returns { ok }
*/ */
export async function deleteRemoteProject(id) { export async function deleteRemoteProject(id) {
return request('DELETE', `/projects/${id}`); return request('DELETE', `/projects/${id}`, undefined, { keepalive: true });
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -98,6 +103,21 @@ export async function disableSharing(id) {
return request('DELETE', `/projects/${id}/share`); return request('DELETE', `/projects/${id}/share`);
} }
/** The project's read-only outline link: { shareToken } (null when off). A separate link from the study share. */
export async function getOutlineShareStatus(id) {
return request('GET', `/projects/${id}/outline-share`);
}
export async function enableOutlineSharing(id) {
return request('POST', `/projects/${id}/outline-share`);
}
export async function disableOutlineSharing(id) {
return request('DELETE', `/projects/${id}/outline-share`);
}
/** Public lookup — no session. Returns { title, translation, outlines: [{ scope, label, tree }] }. */
export async function getSharedOutline(token) {
return request('GET', `/share-outline/${token}`);
}
/** Public lookup — no session required. Returns the full project for a valid share token. */ /** Public lookup — no session required. Returns the full project for a valid share token. */
export async function getSharedProject(token) { export async function getSharedProject(token) {
return request('GET', `/share/${token}`); return request('GET', `/share/${token}`);
+5
View File
@@ -1,4 +1,9 @@
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import { beforeEach } from 'vitest';
import { clearChapterCache } from './utils/bibleText.js';
// Chapter text is cached for the visit; every test starts with a clean slate.
beforeEach(() => clearChapterCache());
// Node 22+ defines experimental localStorage/sessionStorage globals that are // Node 22+ defines experimental localStorage/sessionStorage globals that are
// undefined unless --localstorage-file is passed. Vitest's jsdom environment // undefined unless --localstorage-file is passed. Vitest's jsdom environment
+43
View File
@@ -97,6 +97,17 @@ describe('htmlToPlainText', () => {
// renderVerseContent // renderVerseContent
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe('renderVerseContent', () => { describe('renderVerseContent', () => {
test('a footnote marker between two words does not run them together', () => {
expect(renderVerseContent(['Let there be light,”', { noteId: 0 }, 'and there was light.'])).toBe('Let there be light,” and there was light.');
expect(renderVerseContent(['an expanse', { noteId: 2 }, 'between the waters'])).toBe('an expanse between the waters');
});
test('a footnote marker is not given a space where punctuation or a space already follows', () => {
expect(renderVerseContent(['the earth', { noteId: 1 }, ', and'])).toBe('the earth, and');
expect(renderVerseContent(['the earth ', { noteId: 1 }, 'and'])).toBe('the earth and');
expect(renderVerseContent(['the end.', { noteId: 3 }])).toBe('the end.');
});
test('returns a string unchanged', () => { test('returns a string unchanged', () => {
expect(renderVerseContent('hello world')).toBe('hello world'); expect(renderVerseContent('hello world')).toBe('hello world');
}); });
@@ -635,3 +646,35 @@ describe('buildClaudePrompt', () => {
expect(prompt).not.toContain('Verse three.'); expect(prompt).not.toContain('Verse three.');
}); });
}); });
// ---------------------------------------------------------------------------
// The outline in exports
// ---------------------------------------------------------------------------
describe('outline in exports', () => {
const outlined = {
...baseProject,
outline: [
{ id: 'a', text: 'Greeting <b>', ref: 'Titus 1:1–4', chunkId: null, children: [{ id: 'a1', text: 'Servant of God', ref: '', chunkId: null, children: [] }] },
{ id: 'b', text: 'Qualifications', ref: '', chunkId: null, children: [] },
],
};
test('HTML shows the outline with labels, nested, escaped, before the chunks', () => {
const html = buildExportHtml(outlined);
expect(html).toContain('class="outline"');
expect(html).toContain('<span class="lbl">I.</span>Greeting &lt;b&gt;');
expect(html).toContain('<span class="lbl">A.</span>Servant of God');
expect(html).toContain('<span class="lbl">II.</span>Qualifications');
expect(html.indexOf('class="outline"')).toBeLessThan(html.indexOf('class="chunk"'));
});
test('Markdown shows the outline under its own heading', () => {
const md = buildMarkdownExport(outlined);
expect(md).toContain('## Outline\n\n- **I.** Greeting <b> (Titus 1:1–4)\n - **A.** Servant of God\n- **II.** Qualifications');
});
test('a study without an outline exports as before', () => {
expect(buildExportHtml(baseProject)).not.toContain('class="outline"');
expect(buildMarkdownExport(baseProject)).not.toContain('## Outline');
});
});
+87
View File
@@ -0,0 +1,87 @@
// "Download my data" / "Restore": everything you've made in one JSON file you keep — studies, bookmarks,
// highlights (and what you named the colours), ink notes and reading plans. Restoring only ever adds or
// updates; it never deletes anything you already have here.
import { loadProjectIndex } from './bibleUtils.jsx';
import { loadProjectById, saveProjectToStorage } from './projectStorage.js';
export const BACKUP_APP = 'bible-study-project';
export const BACKUP_VERSION = 1;
// `reader` is what the app holds in memory: { bookmarks, highlights, colorLabels, ink }.
export function buildBackup({ reader, readingPlans, email = null, now = new Date() }) {
const projects = loadProjectIndex().map((e) => loadProjectById(e.id)).filter(Boolean);
return {
app: BACKUP_APP,
version: BACKUP_VERSION,
exportedAt: now.toISOString(),
account: email,
projects,
reader: {
bookmarks: reader?.bookmarks ?? {},
highlights: reader?.highlights ?? [],
colorLabels: reader?.colorLabels ?? {},
ink: reader?.ink ?? {},
},
readingPlans: readingPlans ?? { progress: [], customPlans: [], activity: [] },
};
}
export const backupFileName = (now = new Date()) => `bible-study-backup-${now.toISOString().slice(0, 10)}.json`;
// -> { ok: true, backup, summary } or { ok: false, error }. Checks the file really is one of ours.
export function validateBackup(data) {
if (!data || typeof data !== 'object' || data.app !== BACKUP_APP) return { ok: false, error: 'That doesn’t look like a Bible Study backup file.' };
if (typeof data.version !== 'number' || data.version > BACKUP_VERSION) return { ok: false, error: 'This backup was made by a newer version of the app.' };
const projects = Array.isArray(data.projects) ? data.projects.filter((p) => p && typeof p.id === 'string' && p.title && Array.isArray(p.chapters)) : [];
const reader = data.reader && typeof data.reader === 'object' ? data.reader : {};
const bookmarks = reader.bookmarks && typeof reader.bookmarks === 'object' && !Array.isArray(reader.bookmarks) ? reader.bookmarks : {};
const highlights = Array.isArray(reader.highlights) ? reader.highlights.filter((h) => h && typeof h.id === 'string') : [];
const colorLabels = reader.colorLabels && typeof reader.colorLabels === 'object' && !Array.isArray(reader.colorLabels) ? reader.colorLabels : {};
const ink = reader.ink && typeof reader.ink === 'object' && !Array.isArray(reader.ink) ? reader.ink : {};
const plans = data.readingPlans && typeof data.readingPlans === 'object' ? data.readingPlans : {};
const backup = {
projects, reader: { bookmarks, highlights, colorLabels, ink },
readingPlans: {
progress: Array.isArray(plans.progress) ? plans.progress : [],
customPlans: Array.isArray(plans.customPlans) ? plans.customPlans : [],
activity: Array.isArray(plans.activity) ? plans.activity : [],
},
};
return {
ok: true, backup,
summary: {
projects: projects.length, bookmarks: Object.keys(bookmarks).length, highlights: highlights.length,
inkPages: Object.keys(ink).length, plans: backup.readingPlans.progress.length + backup.readingPlans.customPlans.length,
},
};
}
// Brings the studies back onto this device: a study that isn't here is added, one that is here is
// replaced only when the backup's copy was edited more recently. -> the projects that were written
// (so the caller can send them to the server too).
export function restoreProjects(projects) {
const written = [];
for (const project of projects) {
const here = loadProjectById(project.id);
if (here && (here.lastEdited ?? 0) >= (project.lastEdited ?? 0)) continue;
written.push(saveProjectToStorage(project));
}
return written;
}
// Reading-plan progress from a backup combined with what is here: each plan run is kept once, and the
// copy with the later activity wins; custom plans and activity days are unioned.
export function mergeReadingPlans(local, incoming) {
const progress = new Map((local.progress ?? []).map((p) => [p.id, p]));
for (const p of incoming.progress ?? []) {
const mine = progress.get(p.id);
if (!mine || (p.lastActivityAt ?? 0) > (mine.lastActivityAt ?? 0)) progress.set(p.id, p);
}
const customPlans = new Map((local.customPlans ?? []).map((p) => [p.id, p]));
for (const p of incoming.customPlans ?? []) if (!customPlans.has(p.id)) customPlans.set(p.id, p);
return {
progress: [...progress.values()],
customPlans: [...customPlans.values()],
activity: [...new Set([...(local.activity ?? []), ...(incoming.activity ?? [])])].sort(),
};
}
+58
View File
@@ -0,0 +1,58 @@
import { beforeEach, expect, test } from 'vitest';
import { backupFileName, buildBackup, mergeReadingPlans, restoreProjects, validateBackup } from './backup.js';
import { loadProjectById, saveProjectToStorage } from './projectStorage.js';
import { switchStorageUser } from './bibleUtils.jsx';
const project = (id, lastEdited, title = 'Romans') => ({
id, title, translation: 'BSB', lastEdited, selectedChunkId: null,
chapters: [{ book: 'Romans', bookAbbrev: 'ROM', chapter: '8', verses: [{ number: 1, text: 'x' }], chunks: [] }],
});
beforeEach(() => { localStorage.clear(); switchStorageUser(null); });
test('a backup holds the studies, marks and plans, and round-trips through validation', () => {
saveProjectToStorage(project('p1', 100));
const backup = buildBackup({
reader: { bookmarks: { 'JHN-3-16': '#fef08a' }, highlights: [{ id: 'h1' }], colorLabels: { '#fef08a': 'Key' }, ink: { JHN_3: [] } },
readingPlans: { progress: [{ id: 'r1' }], customPlans: [], activity: ['2026-09-01'] },
now: new Date('2026-10-01T12:00:00Z'),
});
expect(backup.projects.map((p) => p.id)).toEqual(['p1']);
const checked = validateBackup(JSON.parse(JSON.stringify(backup)));
expect(checked.ok).toBe(true);
expect(checked.summary).toEqual({ projects: 1, bookmarks: 1, highlights: 1, inkPages: 1, plans: 1 });
expect(backupFileName(new Date('2026-10-01T12:00:00Z'))).toBe('bible-study-backup-2026-10-01.json');
});
test('rejects files that are not backups, or are from a newer version', () => {
expect(validateBackup(null).ok).toBe(false);
expect(validateBackup({ app: 'something-else', version: 1 }).ok).toBe(false);
expect(validateBackup({ app: 'bible-study-project', version: 99 }).ok).toBe(false);
});
test('ignores malformed entries instead of failing', () => {
const checked = validateBackup({ app: 'bible-study-project', version: 1, projects: [{ id: 'ok', title: 'T', chapters: [] }, { nope: true }, null], reader: { highlights: [{ id: 'h' }, 5] } });
expect(checked.ok).toBe(true);
expect(checked.summary).toMatchObject({ projects: 1, highlights: 1 });
});
test('restoring adds missing studies and only replaces older copies', () => {
saveProjectToStorage(project('newer', 500, 'Local newer'));
saveProjectToStorage(project('older', 100, 'Local older'));
const written = restoreProjects([project('newer', 200, 'Backup'), project('older', 300, 'Backup newer'), project('missing', 50, 'Brought back')]);
expect(written.map((p) => p.id).sort()).toEqual(['missing', 'older']);
expect(loadProjectById('newer').title).toBe('Local newer');
expect(loadProjectById('older').title).toBe('Backup newer');
expect(loadProjectById('missing').title).toBe('Brought back');
});
test('merging plans keeps every run once, preferring the later activity', () => {
const merged = mergeReadingPlans(
{ progress: [{ id: 'a', lastActivityAt: 10, completedDays: [1] }], customPlans: [{ id: 'c1' }], activity: ['2026-09-02'] },
{ progress: [{ id: 'a', lastActivityAt: 20, completedDays: [1, 2] }, { id: 'b', lastActivityAt: 5 }], customPlans: [{ id: 'c1' }, { id: 'c2' }], activity: ['2026-09-01', '2026-09-02'] },
);
expect(merged.progress.find((p) => p.id === 'a').completedDays).toEqual([1, 2]);
expect(merged.progress).toHaveLength(2);
expect(merged.customPlans.map((p) => p.id)).toEqual(['c1', 'c2']);
expect(merged.activity).toEqual(['2026-09-01', '2026-09-02']);
});
+52
View File
@@ -0,0 +1,52 @@
// The Bibles the Reader can show, and one way to fetch a chapter of any of them. Used by the
// main reading text, the side-by-side view, and the verse "Compare" tab.
import { CHAPTER_COUNTS, parseBibleChapter } from './bibleUtils.jsx';
export const READER_TRANSLATIONS = [
{ id: 'BSB', name: 'Berean Standard Bible' },
{ id: 'ESV', name: 'English Standard Version', licensed: true,
notice: 'Scripture quotations are from the ESV® Bible (The Holy Bible, English Standard Version®), © 2001 by Crossway, a publishing ministry of Good News Publishers. Used by permission. All rights reserved.' },
{ id: 'NET', name: 'NET Bible', helloao: 'eng_net',
notice: 'Scripture quoted by permission. Quotations designated (NET) are from the NET Bible® copyright ©1996-2017 by Biblical Studies Press, L.L.C. http://netbible.com All rights reserved.' },
{ id: 'NLT', name: 'New Living Translation', licensed: true,
notice: 'Holy Bible, New Living Translation, Copyright © 2014, Tyndale House Publishers. All rights reserved. tyndale.com' },
{ id: 'CSB', name: 'Christian Standard Bible', licensed: true,
notice: 'Christian Standard Bible® and CSB® are federally registered trademarks of Holman Bible Publishers. All rights reserved. bhpublishinggroup.com' },
{ id: 'NASB', name: 'New American Standard Bible', licensed: true,
notice: 'New American Standard Bible®, Copyright © 1995, The Lockman Foundation. All rights reserved. lockman.org' },
];
export const translationName = (id) => READER_TRANSLATIONS.find((t) => t.id === id)?.name ?? id;
export const isLicensed = (id) => READER_TRANSLATIONS.find((t) => t.id === id)?.licensed === true;
// Free translations are kept for the visit so flipping between chapters, the side-by-side
// view and Compare don't re-download. Licensed text is never kept here (their terms limit
// storage); the server holds it briefly.
const freeChapters = new Map(); // "BSB/JHN/3" -> { verses, totalChapters }
export const clearChapterCache = () => freeChapters.clear();
// -> { verses: [{ number, text }], copyright, totalChapters }. Throws Error(message) on failure.
// Pass `signal` to cancel a request that is no longer wanted.
export async function fetchChapter(translation, bookAbbrev, chapter, { signal } = {}) {
const source = READER_TRANSLATIONS.find((t) => t.id === translation) ?? READER_TRANSLATIONS[0];
let totalChapters = CHAPTER_COUNTS[bookAbbrev] ?? chapter;
if (!source.licensed) {
const key = `${source.id}/${bookAbbrev}/${chapter}`;
const hit = freeChapters.get(key);
if (hit) return { verses: hit.verses, copyright: '', totalChapters: hit.totalChapters };
const res = await fetch(`https://bible.helloao.org/api/${source.helloao ?? source.id}/${bookAbbrev}/${chapter}.json`, { signal });
if (!res.ok) throw new Error('Unable to load chapter.');
const data = await res.json();
const verses = parseBibleChapter(data);
if (!Array.isArray(verses) || verses.length === 0) throw new Error('Invalid Bible data returned.');
totalChapters = data.book?.numberOfChapters ?? totalChapters;
freeChapters.set(key, { verses, totalChapters });
return { verses, copyright: '', totalChapters };
}
const res = await fetch(`/api/bible/${source.id}/${bookAbbrev}/${chapter}`, { credentials: 'include', signal });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Unable to load ${source.id}.`);
if (!Array.isArray(data.verses) || data.verses.length === 0) throw new Error('Invalid Bible data returned.');
return { verses: data.verses, copyright: data.copyright ?? '', totalChapters };
}
+108 -1
View File
@@ -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,91 @@ 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 meta = [subtitle, total > 0 ? `About ${total} minute${total === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · ');
// Simple and calm: paper tones, a serif face, hanging numerals in the accent colour, and generous space.
const style = `
:root { --text: #2b2620; --sub: #6b6458; --accent: #a84e27; --line: #e0d8c6; --paper: #faf7f0; }
* { box-sizing: border-box; }
body { font-family: Georgia, 'Times New Roman', serif; color: var(--text); background: var(--paper); margin: 0; padding: 48px 24px 64px; -webkit-font-smoothing: antialiased; }
.page { max-width: 720px; margin: 0 auto; }
.eyebrow { font: 600 0.72rem/1 system-ui, sans-serif; letter-spacing: 0.18em; text-transform: uppercase; color: var(--accent); margin: 0 0 0.9rem; }
h1 { margin: 0; font-size: 2.1rem; line-height: 1.2; font-weight: 600; letter-spacing: -0.01em; }
.sub { font: 0.9rem/1.4 system-ui, sans-serif; color: var(--sub); margin: 0.6rem 0 0; }
.rule { height: 1px; background: var(--line); margin: 1.6rem 0 0.4rem; border: 0; }
.outline ul { list-style: none; margin: 0; padding: 0; }
.outline ul ul { margin-left: 2.4rem; }
.outline li { margin: 0.55rem 0; line-height: 1.55; font-size: 1.05rem; }
.outline > ul > li { margin-top: 1.5rem; font-size: 1.15rem; font-weight: 600; }
.outline > ul > li li { font-weight: 400; font-size: 1.05rem; }
.lbl { display: inline-block; min-width: 2.4rem; color: var(--accent); font-weight: 600; font-variant-numeric: tabular-nums; }
.ref { color: var(--sub); font: 0.82em/1 system-ui, sans-serif; font-weight: 400; white-space: nowrap; }
.kind { font: 600 0.62rem/1 system-ui, sans-serif; text-transform: uppercase; letter-spacing: 0.1em; color: var(--accent); border: 1px solid #e4b9a3; border-radius: 9999px; padding: 0.2em 0.65em; margin-right: 0.5em; vertical-align: 0.15em; }
.o-note { margin: 0.25rem 0 0 2.4rem; color: var(--sub); font-size: 0.95rem; font-weight: 400; font-style: italic; }
.o-quote { margin: 0.5rem 0 0.4rem 2.4rem; padding: 0.1rem 0 0.1rem 1rem; border-left: 3px solid #e4b9a3; font-style: italic; font-weight: 400; font-size: 1rem; }
.o-attach { margin: 0.3rem 0 0 2.4rem; font: 0.8rem/1.5 system-ui, sans-serif; color: var(--sub); font-weight: 400; }
.foot { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid var(--line); font: 0.72rem/1 system-ui, sans-serif; color: var(--sub); text-align: center; letter-spacing: 0.04em; }
@media (max-width: 520px) { body { padding: 32px 18px 48px; } h1 { font-size: 1.7rem; } .outline ul ul { margin-left: 1.5rem; } .o-note, .o-quote, .o-attach { margin-left: 1.5rem; } }
@media print {
body { background: #fff; padding: 0; }
.page { max-width: none; }
li { break-inside: avoid; }
.outline > ul > li { break-after: 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">
<p class="eyebrow">Outline</p>
<h1>${escHtml(title)}</h1>
${meta ? `<p class="sub">${escHtml(meta)}</p>` : ''}
<hr class="rule" />
<div class="outline">${outlineListHtml(tree, 0)}</div>
<p class="foot">Bible Study Project</p>
</div>
</body>
</html>`;
}
export function wordTableHtml(rows) { export function wordTableHtml(rows) {
return ` return `
<table> <table>
@@ -341,7 +437,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) {
+67
View File
@@ -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,59 @@ 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 }) => {
const main = depth === 0;
return [
new Paragraph({
indent: { left: 480 * depth + 480, hanging: 480 },
spacing: { before: main ? 260 : 80, after: 60, line: 300 },
keepNext: main,
children: [
new TextRun({ text: `${label}\t`, bold: true, color: 'A84E27', size: main ? 26 : 23 }),
...(node.kind ? [new TextRun({ text: `${kindLabel(node.kind).toUpperCase()} `, bold: true, color: 'A84E27', size: 16, characterSpacing: 20 })] : []),
new TextRun({ text: (node.text ?? '').trim() || '(untitled)', bold: main, size: main ? 26 : 23 }),
...(node.ref ? [new TextRun({ text: ` ${node.ref}`, color: '7A7060', size: 19 })] : []),
...(Number.isFinite(node.minutes) && node.minutes > 0 ? [new TextRun({ text: ` ${node.minutes} min`, color: '7A7060', size: 19 })] : []),
],
}),
// note, then the verse's words (set off with a rule), then attachments
...(node.note ?? '').split('\n').filter((l) => l.trim()).map((line) => new Paragraph({
indent: { left: 480 * depth + 480 },
spacing: { after: 40 },
children: [new TextRun({ text: line.trim(), italics: true, color: '6B6458', size: 21 })],
})),
...(node.scripture?.text ? [new Paragraph({
indent: { left: 480 * depth + 600 },
spacing: { before: 40, after: 60 },
border: { left: { style: BorderStyle.SINGLE, size: 18, color: 'E4B9A3', space: 8 } },
children: [
new TextRun({ text: `\u201C${node.scripture.text}\u201D`, italics: true, size: 21 }),
new TextRun({ text: ` \u2014 ${node.scripture.ref}`, color: '7A7060', size: 19 }),
],
})] : []),
...(node.attachments ?? []).map((a) => new Paragraph({
indent: { left: 480 * depth + 480 },
spacing: { after: 20 },
children: [new TextRun({ text: `${a.type === 'word' ? 'Word' : 'See'}: ${a.label}${a.detail ? ` \u2014 ${a.detail}` : ''}`, color: '7A7060', size: 19 })],
})),
];
});
// Just one outline, as its own Word document.
export async function buildOutlineDocxBlob({ title, subtitle = '', tree }) {
const meta = [subtitle, timeSummary(tree)].filter(Boolean).join(' \u00B7 ');
const children = [
new Paragraph({ children: [new TextRun({ text: 'OUTLINE', bold: true, color: 'A84E27', size: 18, characterSpacing: 60 })], spacing: { after: 100 } }),
new Paragraph({ text: title, heading: HeadingLevel.TITLE }),
...(meta ? [new Paragraph({ children: [new TextRun({ text: meta, color: '7A7060', size: 21 })], spacing: { after: 160 } })] : []),
new Paragraph({ border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: 'E0D8C6', space: 1 } }, spacing: { after: 120 } }),
...outlineParagraphs(tree),
];
const doc = new Document({ styles: DOCX_STYLES, sections: [{ properties: { page: { margin: { top: 1200, bottom: 1200, left: 1400, right: 1400 } } }, 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 +144,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 +199,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));
+6 -2
View File
@@ -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) {
+16
View File
@@ -0,0 +1,16 @@
// The colours a highlight can be, and the meanings readers tend to give them. The reader names
// each colour themselves (see the Highlights list); these are only the starting suggestions.
export const HIGHLIGHT_COLORS = ['#fef08a', '#bbf7d0', '#bfdbfe', '#fecaca', '#e9d5ff', '#fed7aa'];
export const COLOR_NAMES = {
'#fef08a': 'Yellow', '#bbf7d0': 'Green', '#bfdbfe': 'Blue',
'#fecaca': 'Red', '#e9d5ff': 'Purple', '#fed7aa': 'Orange',
};
export const COLOR_SUGGESTIONS = {
'#fef08a': 'Key verse', '#bbf7d0': 'Promise', '#bfdbfe': 'Command',
'#fecaca': 'Warning', '#e9d5ff': 'Prayer', '#fed7aa': 'Question',
};
// "Promise (green)" when named, otherwise just "Green".
export const colorTitle = (color, labels = {}) => (labels[color] ? `${labels[color]} (${(COLOR_NAMES[color] ?? 'color').toLowerCase()})` : COLOR_NAMES[color] ?? 'Highlight');
+45
View File
@@ -0,0 +1,45 @@
// "Save for offline": fetch chapters ahead of time so they open without a connection. Fetching is
// all it takes — the service worker (public/sw.js) keeps every Bible-text response it sees. Only the
// free translations (BSB, NET) can be saved; the licensed ones' terms don't allow bulk copies.
import { CHAPTER_COUNTS } from './bibleUtils.jsx';
import { fetchChapter, isLicensed } from './bibleText.js';
const KEY = 'offline-saved';
// { 'BSB': ['JHN', 'PSA'] }: whole books saved on this device, per translation.
export function loadSavedBooks() {
try {
const raw = JSON.parse(localStorage.getItem(KEY) || '{}');
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
} catch { return {}; }
}
export function markBookSaved(translation, book) {
const all = loadSavedBooks();
all[translation] = [...new Set([...(all[translation] ?? []), book])];
try { localStorage.setItem(KEY, JSON.stringify(all)); } catch { /* storage full or unavailable */ }
return all;
}
export const canSaveOffline = (translation) => !isLicensed(translation);
export const bookRefs = (book) => Array.from({ length: CHAPTER_COUNTS[book] ?? 0 }, (_, i) => ({ book, chapter: i + 1 }));
export const bibleRefs = () => Object.keys(CHAPTER_COUNTS).flatMap(bookRefs);
// Fetches every chapter in `refs` ([{ book, chapter }]) a few at a time, calling onProgress(done, total)
// as each lands. Resolves { done, failed } — a chapter that fails doesn't stop the rest. Pass `signal`
// to stop early.
export async function downloadChapters(translation, refs, { onProgress, signal, concurrency = 4 } = {}) {
const queue = [...refs];
let done = 0;
let failed = 0;
const worker = async () => {
while (queue.length > 0 && !signal?.aborted) {
const { book, chapter } = queue.shift();
try { await fetchChapter(translation, book, chapter, { signal }); done += 1; } catch { if (!signal?.aborted) failed += 1; }
onProgress?.(done + failed, refs.length);
}
};
await Promise.all(Array.from({ length: Math.min(concurrency, refs.length) }, worker));
return { done, failed };
}
+44
View File
@@ -0,0 +1,44 @@
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { bibleRefs, bookRefs, canSaveOffline, downloadChapters, loadSavedBooks, markBookSaved } from './offline.js';
beforeEach(() => localStorage.clear());
afterEach(() => vi.unstubAllGlobals());
const chapterResponse = { ok: true, json: () => Promise.resolve({ chapter: { content: [{ type: 'verse', number: 1, content: ['In the beginning.'] }] } }) };
test('lists every chapter of a book and of the whole Bible', () => {
expect(bookRefs('JHN')).toHaveLength(21);
expect(bookRefs('JHN')[0]).toEqual({ book: 'JHN', chapter: 1 });
expect(bibleRefs()).toHaveLength(1189);
});
test('only the free translations can be saved', () => {
expect(canSaveOffline('BSB')).toBe(true);
expect(canSaveOffline('NET')).toBe(true);
expect(canSaveOffline('ESV')).toBe(false);
});
test('remembers which books are saved', () => {
markBookSaved('BSB', 'JHN');
markBookSaved('BSB', 'JHN');
markBookSaved('BSB', 'PSA');
expect(loadSavedBooks()).toEqual({ BSB: ['JHN', 'PSA'] });
});
test('downloads every chapter, reports progress, and keeps going past a failure', async () => {
const fetchMock = vi.fn((url) => Promise.resolve(url.includes('/JHN/2.json') ? { ok: false } : chapterResponse));
vi.stubGlobal('fetch', fetchMock);
const progress = [];
const result = await downloadChapters('BSB', bookRefs('JHN').slice(0, 4), { onProgress: (d, t) => progress.push([d, t]) });
expect(result).toEqual({ done: 3, failed: 1 });
expect(fetchMock).toHaveBeenCalledTimes(4);
expect(progress.at(-1)).toEqual([4, 4]);
});
test('stops when told to', async () => {
const controller = new AbortController();
const fetchMock = vi.fn(() => { controller.abort(); return Promise.resolve(chapterResponse); });
vi.stubGlobal('fetch', fetchMock);
await downloadChapters('BSB', bookRefs('PSA'), { signal: controller.signal, concurrency: 1 });
expect(fetchMock.mock.calls.length).toBeLessThan(5);
});
+236
View File
@@ -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];
}
+179
View File
@@ -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);
});
});
+60
View File
@@ -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`);
}
+66
View File
@@ -0,0 +1,66 @@
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 &lt;b&gt;');
expect(html).toContain('&lt;i&gt;hi&lt;/i&gt;');
expect(html).toContain('class="o-note"');
});
test('the Word document builds, with and without the extras', async () => {
const { buildOutlineDocxBlob } = await import('./docxExport.js');
const blob = await buildOutlineDocxBlob({
...outline,
tree: [{ ...outline.tree[0], scripture: { ref: 'Titus 1:5', text: 'appoint elders', translation: 'BSB' }, attachments: [{ id: 'x', type: 'word', label: 'presbyteros', detail: 'elder' }] }, { id: 'z', text: '', ref: '', children: [] }],
});
expect(blob.size).toBeGreaterThan(1000);
expect((await buildOutlineDocxBlob({ title: 'Empty', tree: [] })).size).toBeGreaterThan(500);
});
+34
View File
@@ -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] ?? '';
+51
View File
@@ -0,0 +1,51 @@
// Turns what someone types — "john 3:16", "1 cor 13", "Ps 23", "jn3" — into a place in the Bible.
import { bookOptions, CHAPTER_COUNTS } from './bibleUtils.jsx';
// Short forms people actually type that aren't just the start of the book's name.
const ALIASES = {
gn: 'GEN', ex: 'EXO', exod: 'EXO', lv: 'LEV', nm: 'NUM', nu: 'NUM', dt: 'DEU', deut: 'DEU', jos: 'JOS', josh: 'JOS',
jdg: 'JDG', judg: 'JDG', jg: 'JDG', rt: 'RUT', '1sm': '1SA', '2sm': '2SA', '1ki': '1KI', '2ki': '2KI', '1kgs': '1KI', '2kgs': '2KI',
'1chr': '1CH', '2chr': '2CH', ne: 'NEH', est: 'EST', jb: 'JOB', ps: 'PSA', psa: 'PSA', pslm: 'PSA', psalm: 'PSA', psalms: 'PSA',
pr: 'PRO', prov: 'PRO', prv: 'PRO', ecc: 'ECC', eccl: 'ECC', qoh: 'ECC', song: 'SNG', sos: 'SNG', ss: 'SNG', songofsongs: 'SNG', songofsolomon: 'SNG', canticles: 'SNG',
is: 'ISA', isa: 'ISA', jer: 'JER', lam: 'LAM', ez: 'EZK', ezek: 'EZK', dn: 'DAN', hos: 'HOS', jl: 'JOL', joel: 'JOL',
am: 'AMO', ob: 'OBA', obad: 'OBA', jon: 'JON', mi: 'MIC', mic: 'MIC', na: 'NAM', nah: 'NAM', hab: 'HAB', zep: 'ZEP', zeph: 'ZEP',
hag: 'HAG', zec: 'ZEC', zech: 'ZEC', mal: 'MAL',
mt: 'MAT', matt: 'MAT', mk: 'MRK', mr: 'MRK', mrk: 'MRK', lk: 'LUK', lu: 'LUK', jn: 'JHN', jhn: 'JHN', joh: 'JHN',
ac: 'ACT', rom: 'ROM', rm: 'ROM', '1cor': '1CO', '2cor': '2CO', '1co': '1CO', '2co': '2CO', gal: 'GAL', eph: 'EPH',
phil: 'PHP', php: 'PHP', pp: 'PHP', col: 'COL', '1thess': '1TH', '2thess': '2TH', '1th': '1TH', '2th': '2TH',
'1tim': '1TI', '2tim': '2TI', '1ti': '1TI', '2ti': '2TI', tit: 'TIT', phm: 'PHM', philem: 'PHM', heb: 'HEB',
jas: 'JAS', jm: 'JAS', '1pet': '1PE', '2pet': '2PE', '1pe': '1PE', '2pe': '2PE', '1jn': '1JN', '2jn': '2JN', '3jn': '3JN',
'1john': '1JN', '2john': '2JN', '3john': '3JN', jud: 'JUD', rev: 'REV', rv: 'REV', revelations: 'REV',
};
const squash = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
const BOOKS = bookOptions.map((b) => ({ ...b, key: squash(b.name) }));
// The books a typed name could mean, best first: exact, then a known short form, then any
// book whose name starts with it ("deut" → Deuteronomy, "1 cor" → 1 Corinthians).
export function matchBooks(typed) {
const key = squash(typed);
if (!key) return [];
const exact = BOOKS.filter((b) => b.key === key);
if (exact.length) return exact;
const alias = ALIASES[key] && BOOKS.filter((b) => b.abbrev === ALIASES[key]);
if (alias?.length) return alias;
return BOOKS.filter((b) => b.key.startsWith(key));
}
// "John 3:16" -> [{ book: 'JHN', bookName: 'John', chapter: 3, verse: 16 }]; ambiguous names ("jo")
// give several. A bare book ("romans") opens chapter 1. Chapters past the end are rejected.
export function parsePassageRef(text) {
const m = String(text ?? '').trim().replace(/\./g, ' ').match(/^((?:[1-3]\s*)?[a-z][a-z ]*?)\s*(?:(\d{1,3})(?:\s*[:.v ]\s*(\d{1,3}))?)?$/i);
if (!m) return [];
const [, name, chapterText, verseText] = m;
const out = [];
for (const b of matchBooks(name).slice(0, 4)) {
const chapter = chapterText ? Number(chapterText) : 1;
if (chapter < 1 || chapter > (CHAPTER_COUNTS[b.abbrev] ?? 1)) continue;
out.push({ book: b.abbrev, bookName: b.name, chapter, verse: verseText ? Number(verseText) : null, hasChapter: !!chapterText });
}
return out;
}
export const formatPassage = (r) => `${r.bookName}${r.hasChapter || r.chapter > 1 ? ` ${r.chapter}` : ''}${r.verse ? `:${r.verse}` : ''}`;
+37
View File
@@ -0,0 +1,37 @@
import { expect, test } from 'vitest';
import { formatPassage, matchBooks, parsePassageRef } from './passageRef.js';
const first = (text) => parsePassageRef(text)[0];
test('understands a book, chapter and verse in the usual ways', () => {
expect(first('John 3:16')).toMatchObject({ book: 'JHN', chapter: 3, verse: 16 });
expect(first('jn 3 16')).toMatchObject({ book: 'JHN', chapter: 3, verse: 16 });
expect(first('Ps 23')).toMatchObject({ book: 'PSA', chapter: 23, verse: null });
expect(first('1 cor 13:4')).toMatchObject({ book: '1CO', chapter: 13, verse: 4 });
expect(first('1corinthians 13')).toMatchObject({ book: '1CO', chapter: 13 });
expect(first('song of solomon 2')).toMatchObject({ book: 'SNG', chapter: 2 });
expect(first('Rom. 8.28')).toMatchObject({ book: 'ROM', chapter: 8, verse: 28 });
});
test('a bare book name opens its first chapter', () => {
expect(first('romans')).toMatchObject({ book: 'ROM', chapter: 1, hasChapter: false });
});
test('a name that fits several books lists them; a short form picks one', () => {
expect(matchBooks('jo').length).toBeGreaterThan(1);
expect(matchBooks('jn').map((b) => b.abbrev)).toEqual(['JHN']);
});
test('rejects chapters a book does not have, and things that are not passages', () => {
expect(parsePassageRef('jude 2')).toEqual([]); // Jude has one chapter
expect(parsePassageRef('genesis 51')).toEqual([]);
expect(parsePassageRef('')).toEqual([]);
expect(parsePassageRef('3:16')).toEqual([]);
expect(parsePassageRef('zzz 4')).toEqual([]);
});
test('formats a place the way people write it', () => {
expect(formatPassage(first('john 3:16'))).toBe('John 3:16');
expect(formatPassage(first('psalm 23'))).toBe('Psalms 23');
expect(formatPassage(first('romans'))).toBe('Romans');
});
+23
View File
@@ -0,0 +1,23 @@
// "Luke 1, Luke 2, Luke 3, Luke 5" -> "Luke 1–3, 5": a long run of chapters reads as ranges, so a
// study card stays short. Anything that isn't "Book N" is left as it was.
export function compactPassages(summary) {
if (!summary) return '';
const parts = summary.split(/,\s*/);
const groups = [];
for (const part of parts) {
const m = part.match(/^(.+?)\s+(\d+)$/);
if (!m) { groups.push({ raw: part }); continue; }
const last = groups[groups.length - 1];
if (last && last.book === m[1]) last.chapters.push(Number(m[2]));
else groups.push({ book: m[1], chapters: [Number(m[2])] });
}
return groups.map((g) => {
if (g.raw !== undefined) return g.raw;
const runs = [];
for (const n of g.chapters) {
const run = runs[runs.length - 1];
if (run && n === run[1] + 1) run[1] = n; else runs.push([n, n]);
}
return `${g.book} ${runs.map(([a, b]) => (a === b ? a : b === a + 1 ? `${a}, ${b}` : `${a}–${b}`)).join(', ')}`;
}).join(', ');
}
+17
View File
@@ -0,0 +1,17 @@
import { expect, test } from 'vitest';
import { compactPassages } from './passageSummary.js';
test('runs of chapters become ranges', () => {
expect(compactPassages('Luke 1, Luke 2, Luke 3, Luke 4, Luke 5, Luke 6')).toBe('Luke 1–6');
expect(compactPassages('1 John 1, 1 John 2, 1 John 3, 1 John 4, 1 John 5')).toBe('1 John 1–5');
});
test('gaps stay separate, pairs and singles stay plain', () => {
expect(compactPassages('Luke 1, Luke 2, Luke 3, Luke 5')).toBe('Luke 1–3, 5');
expect(compactPassages('Malachi 1, Malachi 2')).toBe('Malachi 1, 2');
expect(compactPassages('John 3')).toBe('John 3');
});
test('several books, and text that is not a chapter, are kept', () => {
expect(compactPassages('Ephesians 1, Ephesians 2, Ephesians 3, Titus 1')).toBe('Ephesians 1–3, Titus 1');
expect(compactPassages('Whole book')).toBe('Whole book');
expect(compactPassages('')).toBe('');
});
+49
View File
@@ -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];
}
+45
View File
@@ -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([]);
});
+70
View File
@@ -0,0 +1,70 @@
// Search across everything the reader has written or saved on this device: study notes, project
// titles and passages, bookmarks and highlights. (Bible text itself is searched in the Reader.)
import { bookOptions, formatChunkReference } from './bibleUtils.jsx';
const NOTE_FIELDS = [
['observation', 'Observation'], ['interpretation', 'Interpretation'], ['application', 'Application'],
['generalNotes', 'Notes'], ['finalScript', 'Script'],
];
const snippetAround = (raw, at, len) => {
const start = Math.max(0, at - 40);
const end = at + len + 60;
return (start > 0 ? '…' : '') + raw.slice(start, end).replace(/\s+/g, ' ') + (end < raw.length ? '…' : '');
};
// projects: full project objects. -> [{ projectId, projectTitle, chunkId, ref, field, snippet }], at most
// `limit`, one hit per chunk (the first field that matches).
export function searchNotes(projects, query, { limit = 20 } = {}) {
const q = query.trim().toLowerCase();
if (q.length < 3) return [];
const hits = [];
for (const project of projects) {
for (const [chapterIndex, chapter] of (project.chapters ?? []).entries()) {
for (const chunk of chapter.chunks ?? []) {
for (const [field, label] of NOTE_FIELDS) {
const raw = chunk[field] ?? '';
const at = raw.toLowerCase().indexOf(q);
if (at === -1) continue;
hits.push({
projectId: project.id, projectTitle: project.title, chunkId: chunk.id,
ref: formatChunkReference(project, chapterIndex, chunk, '–'), field: label, snippet: snippetAround(raw, at, q.length),
});
break;
}
if (hits.length >= limit) return hits;
}
}
}
return hits;
}
// Projects whose title, passages or tags contain the text.
export function searchProjects(index, query, { limit = 6 } = {}) {
const q = query.trim().toLowerCase();
if (!q) return [];
return index.filter((e) => [e.title, e.chapterSummary, ...(e.tags ?? [])].some((t) => (t ?? '').toLowerCase().includes(q))).slice(0, limit);
}
const bookName = (abbrev) => bookOptions.find((b) => b.abbrev === abbrev)?.name ?? abbrev;
// Highlights (by their words or what the colour is named) and bookmarks (by reference).
// -> [{ kind: 'highlight' | 'bookmark', book, bookName, chapter, verse, color, text?, label? }]
export function searchMarks({ bookmarks = {}, highlights = [], labels = {} }, query, { limit = 10 } = {}) {
const q = query.trim().toLowerCase();
if (q.length < 2) return [];
const out = [];
for (const h of highlights) {
const label = labels[h.color] ?? '';
const ref = `${bookName(h.book)} ${h.chapter}:${h.verse}`.toLowerCase();
if ((h.text ?? '').toLowerCase().includes(q) || label.toLowerCase().includes(q) || ref.includes(q)) {
out.push({ kind: 'highlight', book: h.book, bookName: bookName(h.book), chapter: h.chapter, verse: h.verse, color: h.color, text: h.text, label });
}
}
for (const [key, color] of Object.entries(bookmarks)) {
const [book, chapter, verse] = key.split('-');
const ref = `${bookName(book)} ${chapter}:${verse}`;
if (ref.toLowerCase().includes(q)) out.push({ kind: 'bookmark', book, bookName: bookName(book), chapter: Number(chapter), verse: Number(verse), color });
}
return out.slice(0, limit);
}
+42
View File
@@ -0,0 +1,42 @@
import { expect, test } from 'vitest';
import { searchMarks, searchNotes, searchProjects } from './personalSearch.js';
const project = {
id: 'p1', title: 'Romans study',
chapters: [{
book: 'Romans', bookAbbrev: 'ROM', chapter: '8', verses: [{ number: 1, text: 'a' }, { number: 2, text: 'b' }],
chunks: [
{ id: 'c1', startVerse: 1, endVerse: 2, observation: 'No condemnation for those in Christ.', interpretation: '', application: '', generalNotes: '', finalScript: '' },
{ id: 'c2', startVerse: 2, endVerse: 2, observation: '', interpretation: 'The Spirit of life sets us free', application: '', generalNotes: '', finalScript: '' },
],
}],
};
test('finds notes, one hit per chunk, saying which field matched', () => {
const hits = searchNotes([project], 'spirit');
expect(hits).toHaveLength(1);
expect(hits[0]).toMatchObject({ projectId: 'p1', chunkId: 'c2', field: 'Interpretation' });
expect(hits[0].snippet).toContain('Spirit of life');
});
test('needs three letters before searching notes', () => {
expect(searchNotes([project], 'sp')).toEqual([]);
});
test('finds projects by title, passage or tag', () => {
const index = [{ id: 'a', title: 'Romans study', chapterSummary: 'Romans 8', tags: ['grace'] }, { id: 'b', title: 'Psalms', chapterSummary: 'Psalms 23', tags: [] }];
expect(searchProjects(index, 'rom').map((e) => e.id)).toEqual(['a']);
expect(searchProjects(index, 'grace').map((e) => e.id)).toEqual(['a']);
expect(searchProjects(index, 'zzz')).toEqual([]);
});
test('finds highlights by their words or colour name, and bookmarks by reference', () => {
const marks = {
bookmarks: { 'JHN-3-16': '#fef08a' },
highlights: [{ id: 'h1', book: 'ROM', chapter: 8, verse: 28, color: '#bbf7d0', text: 'all things work together', startOffset: 0, endOffset: 5 }],
labels: { '#bbf7d0': 'Promise' },
};
expect(searchMarks(marks, 'together')[0]).toMatchObject({ kind: 'highlight', bookName: 'Romans', verse: 28 });
expect(searchMarks(marks, 'promise')[0]).toMatchObject({ kind: 'highlight' });
expect(searchMarks(marks, 'john 3')[0]).toMatchObject({ kind: 'bookmark', chapter: 3, verse: 16 });
});
+9
View File
@@ -0,0 +1,9 @@
// Remembers, per study, the server copy's lastEdited that this device last saw — the "base" a save is
// checked against so another device's newer copy is never overwritten silently. Kept in memory: it is
// rebuilt from the server's list each time you sign in.
const base = new Map();
export const getSyncBase = (id) => base.get(id);
export const setSyncBase = (id, lastEdited) => { if (Number.isFinite(lastEdited)) base.set(id, lastEdited); };
export const clearSyncBase = (id) => base.delete(id);
export const resetSyncBases = () => base.clear();
+90
View File
@@ -0,0 +1,90 @@
// How two wordings of the same verse differ: which words are not shared, and how alike they are overall.
// Comparison ignores case and punctuation ("light,”" matches "Light"), and works on whole words.
// Words with their position in the original text: [{ word: 'light', start, end }]
export function tokenize(text) {
const out = [];
const re = /[\p{L}\p{N}][\p{L}\p{N}’'-]*/gu;
let m;
while ((m = re.exec(text ?? '')) !== null) {
out.push({ word: m[0].toLowerCase().replace(/[’']s?$/u, '').replace(/[’'-]/gu, ''), start: m.index, end: m.index + m[0].length });
}
return out;
}
// Longest common subsequence of two word lists -> the indexes kept in each (classic DP; verses are short).
function commonWords(a, b) {
const n = a.length;
const m = b.length;
const dp = Array.from({ length: n + 1 }, () => new Uint16Array(m + 1));
for (let i = n - 1; i >= 0; i -= 1) {
for (let j = m - 1; j >= 0; j -= 1) {
dp[i][j] = a[i].word === b[j].word ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
const inA = new Set();
const inB = new Set();
let i = 0;
let j = 0;
while (i < n && j < m) {
if (a[i].word === b[j].word) { inA.add(i); inB.add(j); i += 1; j += 1; }
else if (dp[i + 1][j] >= dp[i][j + 1]) i += 1;
else j += 1;
}
return { inA, inB };
}
// Joins neighbouring changed words into one range, so a changed phrase is one highlight, not many.
function ranges(tokens, kept, text) {
const out = [];
tokens.forEach((t, i) => {
if (kept.has(i)) return;
const last = out[out.length - 1];
if (last && /^\s*$/.test(text.slice(last.end, t.start))) last.end = t.end;
else out.push({ start: t.start, end: t.end });
});
return out;
}
// -> { a: [{ start, end }], b: [{ start, end }], similarity: 0..1, same: bool }
// `a` and `b` are the character ranges (in each text) of words the other does not have.
export function diffVerses(textA, textB) {
const a = tokenize(textA);
const b = tokenize(textB);
if (a.length === 0 && b.length === 0) return { a: [], b: [], similarity: 1, same: true };
const { inA, inB } = commonWords(a, b);
const similarity = (2 * inA.size) / (a.length + b.length);
return { a: ranges(a, inA, textA), b: ranges(b, inB, textB), similarity, same: inA.size === a.length && inB.size === b.length };
}
// A whole chapter: pairs of [mainVerse, otherVerse] by verse number ->
// { similarity, differing, total, verses: [{ number, similarity, ...ranges }] sorted most different first }
export function diffChapter(mainVerses, otherVerses) {
const other = new Map(otherVerses.map((v) => [v.number, v]));
let shared = 0;
let words = 0;
const verses = [];
for (const v of mainVerses) {
const o = other.get(v.number);
if (!o) continue;
const d = diffVerses(v.text, o.text);
const na = tokenize(v.text).length;
const nb = tokenize(o.text).length;
shared += (d.similarity * (na + nb)) / 2;
words += (na + nb) / 2;
verses.push({ number: v.number, ...d });
}
return {
similarity: words === 0 ? 1 : shared / words,
total: verses.length,
differing: verses.filter((v) => !v.same).length,
verses,
mostDifferent: [...verses].filter((v) => !v.same).sort((x, y) => x.similarity - y.similarity).slice(0, 3),
};
}
// "12% different" for a similarity of 0.88; "identical words" at 1.
export const differenceLabel = (similarity) => {
const pct = Math.round((1 - similarity) * 100);
return pct === 0 && similarity < 1 ? '<1% different' : pct === 0 ? 'Same words' : `${pct}% different`;
};
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, test } from 'vitest';
import { diffChapter, diffVerses, differenceLabel, tokenize } from './textDiff.js';
const slice = (text, ranges) => ranges.map((r) => text.slice(r.start, r.end));
describe('tokenize', () => {
test('words only, lowercased, with their place in the text', () => {
const t = tokenize('God said, “Let there be light,”');
expect(t.map((x) => x.word)).toEqual(['god', 'said', 'let', 'there', 'be', 'light']);
expect(t[0]).toMatchObject({ start: 0, end: 3 });
});
test('possessives and apostrophes do not split or change words', () => {
expect(tokenize('God’s law').map((x) => x.word)).toEqual(['god', 'law']);
expect(tokenize("God's law").map((x) => x.word)).toEqual(['god', 'law']);
});
});
describe('diffVerses', () => {
test('identical wording, whatever the punctuation or case, is the same', () => {
const d = diffVerses('In the beginning God created the heavens and the earth.', 'in the beginning, God created the heavens and the earth');
expect(d).toMatchObject({ same: true, similarity: 1, a: [], b: [] });
});
test('marks the words each side has that the other does not', () => {
const a = 'The earth was formless and void';
const b = 'The earth was without shape and empty';
const d = diffVerses(a, b);
expect(slice(a, d.a)).toEqual(['formless', 'void']);
expect(slice(b, d.b)).toEqual(['without shape', 'empty']); // neighbouring words become one range
expect(d.same).toBe(false);
expect(d.similarity).toBeCloseTo((2 * 4) / 13, 5);
});
test('a pure addition shows only on the side that has it', () => {
const d = diffVerses('God saw the light', 'God saw that the light was good');
expect(d.a).toEqual([]);
expect(d.b.length).toBeGreaterThan(0);
});
test('totally different text is not alike at all; two empty ones are the same', () => {
expect(diffVerses('alpha beta', 'gamma delta').similarity).toBe(0);
expect(diffVerses('', '').same).toBe(true);
});
});
describe('diffChapter', () => {
const main = [{ number: 1, text: 'In the beginning God created.' }, { number: 2, text: 'The earth was formless.' }, { number: 3, text: 'Let there be light.' }];
const other = [{ number: 1, text: 'In the beginning God created.' }, { number: 2, text: 'The earth was shapeless and void.' }, { number: 4, text: 'Extra verse only here.' }];
test('scores the chapter over verses both have, and lists the most different first', () => {
const c = diffChapter(main, other);
expect(c.total).toBe(2); // verse 3 and 4 are not in both
expect(c.differing).toBe(1);
expect(c.mostDifferent.map((v) => v.number)).toEqual([2]);
expect(c.similarity).toBeGreaterThan(0.7);
expect(c.similarity).toBeLessThan(1);
});
test('an identical chapter is fully alike', () => {
expect(diffChapter(main, main)).toMatchObject({ similarity: 1, differing: 0, mostDifferent: [] });
});
});
describe('differenceLabel', () => {
test('says it in percent, and never "0% different" for something that does differ', () => {
expect(differenceLabel(0.88)).toBe('12% different');
expect(differenceLabel(1)).toBe('Same words');
expect(differenceLabel(0.9999)).toBe('<1% different');
});
});