Compare commits
17
Commits
No files matched your search
@@ -83,6 +83,10 @@ export function initDb() {
|
||||
if (!projectCols.some((c) => c.name === 'share_token')) {
|
||||
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');
|
||||
|
||||
// Reader ink — one row per user + book + chapter, stored as JSON
|
||||
@@ -414,6 +418,28 @@ export function clearShareToken(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. */
|
||||
export function getProjectByShareToken(token) {
|
||||
const row = db.prepare('SELECT data FROM projects WHERE share_token = ?').get(token);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects,
|
||||
enableTotp, disableTotp, setBackupCodeHashes, setPodcastName,
|
||||
getShareToken, setShareToken, clearShareToken, getProjectByShareToken,
|
||||
getOutlineShareToken, setOutlineShareToken, clearOutlineShareToken, getProjectByOutlineToken,
|
||||
adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject,
|
||||
setUserPassword, destroyAllSessionsForUser,
|
||||
getAllReaderInk, setReaderInkPage, getReaderMarks, setReaderMarks, getUserPrefs, setUserPrefs,
|
||||
@@ -27,6 +28,7 @@ import { registerBibleRoutes } from './bibleApi.js';
|
||||
import { cleanMarks } from './readerMarks.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).
|
||||
function userPayload(user) {
|
||||
@@ -599,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
|
||||
app.get('/api/share/:token', (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
+195
-1
@@ -18,7 +18,7 @@ const json = (status, data) => Promise.resolve({ ok: status < 400, status, json:
|
||||
// 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 };
|
||||
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 });
|
||||
@@ -27,6 +27,18 @@ function stubServer({ user = null, putStatus = 200 } = {}) {
|
||||
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 })
|
||||
@@ -224,6 +236,138 @@ describe('Outline stage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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(', ');
|
||||
@@ -247,3 +391,53 @@ describe('Window title', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
+8
-1
@@ -5,6 +5,8 @@ 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 Tooltips from './components/Tooltips.jsx';
|
||||
import { useAudio } from './hooks/useAudio.js';
|
||||
import { useAuth } from './hooks/useAuth.js';
|
||||
import { useBibleReader } from './hooks/useBibleReader.js';
|
||||
@@ -149,7 +151,7 @@ const AppInner = () => {
|
||||
page: currentPage,
|
||||
setPage: setCurrentPage,
|
||||
hasProject: !!project,
|
||||
enabled: !new URLSearchParams(window.location.search).has('share'),
|
||||
enabled: !new URLSearchParams(window.location.search).has('share') && !new URLSearchParams(window.location.search).has('outline'),
|
||||
onArrive: (page) => { if (page === 'home') setProjectIndex(loadProjectIndex()); },
|
||||
});
|
||||
|
||||
@@ -436,6 +438,7 @@ const AppInner = () => {
|
||||
</Suspense>
|
||||
{paletteOpen && <CommandPalette onClose={() => setPaletteOpen(false)} />}
|
||||
{helpOpen && <ShortcutsHelp onClose={() => setHelpOpen(false)} />}
|
||||
<Tooltips theme={reader.readerTheme} />
|
||||
</AppContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -461,6 +464,10 @@ function PageLoading() {
|
||||
function AppRouter() {
|
||||
const { currentPage, authUser, authServerDown } = useApp();
|
||||
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 (sharedError) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,8 @@ const BY_PAGE = {
|
||||
['/', 'Search this chapter or the whole Bible'],
|
||||
['G', 'Pick a book and chapter'],
|
||||
['B', 'Bookmark the selected verse'],
|
||||
['C', 'Compare translations'],
|
||||
['H', 'Your highlights'],
|
||||
[',', 'Reading settings'],
|
||||
['F', 'Hide or show the controls'],
|
||||
],
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// Hover (and keyboard-focus) tooltips for the whole app, without touching every button:
|
||||
// - anything with data-tip="…" shows that text (used for shortcut hints like "Search (/)")
|
||||
// - anything with title="…" shows it too, in the app's style and without the browser's long delay
|
||||
// - an icon-only button (no visible words) shows its aria-label
|
||||
// Touch screens have no hover, so nothing appears there.
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { themeVars } from './reader/readerThemes.js';
|
||||
|
||||
const SHOW_DELAY = 450;
|
||||
const SELECTOR = '[data-tip], [title], button[aria-label], a[aria-label], [role="button"][aria-label], [role="tab"][aria-label]';
|
||||
|
||||
const canHover = () => (typeof window.matchMedia === 'function' ? window.matchMedia('(hover: hover)').matches : true);
|
||||
|
||||
// The words a tooltip should show for an element, or '' for none. A title is moved to data-tip the first
|
||||
// time (so the browser's own tooltip doesn't also appear); a title on a button with no other name becomes
|
||||
// its aria-label so screen readers keep it.
|
||||
export function tipFor(el) {
|
||||
if (!(el instanceof HTMLElement)) return '';
|
||||
if (el.hasAttribute('title')) {
|
||||
const title = el.getAttribute('title').trim();
|
||||
if (title) {
|
||||
if (!el.getAttribute('aria-label') && el.textContent.replace(/\s+/g, '').length <= 2) el.setAttribute('aria-label', title);
|
||||
if (!el.hasAttribute('data-tip')) el.setAttribute('data-tip', title);
|
||||
}
|
||||
el.removeAttribute('title');
|
||||
}
|
||||
const tip = el.getAttribute('data-tip');
|
||||
if (tip) return tip;
|
||||
// Icon-only controls (little or no text of their own) explain themselves with their label
|
||||
const label = el.getAttribute('aria-label');
|
||||
const visible = el.textContent.replace(/\s+/g, '').length;
|
||||
return label && visible <= 2 ? label : '';
|
||||
}
|
||||
|
||||
export default function Tooltips({ theme }) {
|
||||
const [tip, setTip] = useState(null); // { text, x, y, below }
|
||||
const timer = useRef(null);
|
||||
const current = useRef(null);
|
||||
const box = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const hide = () => {
|
||||
window.clearTimeout(timer.current);
|
||||
if (current.current) current.current.removeAttribute('aria-describedby');
|
||||
current.current = null;
|
||||
setTip(null);
|
||||
};
|
||||
const show = (el, text) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
current.current = el;
|
||||
el.setAttribute('aria-describedby', 'app-tooltip');
|
||||
setTip({ text, x: r.left + r.width / 2, y: r.top, bottom: r.bottom });
|
||||
};
|
||||
const find = (node) => (node instanceof Element ? node.closest(SELECTOR) : null);
|
||||
|
||||
const onOver = (e) => {
|
||||
if (!canHover()) return;
|
||||
const el = find(e.target);
|
||||
if (!el || el === current.current) return;
|
||||
hide();
|
||||
const text = tipFor(el);
|
||||
if (!text || el.disabled) return;
|
||||
timer.current = window.setTimeout(() => show(el, text), SHOW_DELAY);
|
||||
};
|
||||
const onOut = (e) => {
|
||||
const el = find(e.target);
|
||||
if (!el || (e.relatedTarget instanceof Node && el.contains(e.relatedTarget))) return;
|
||||
hide();
|
||||
};
|
||||
const onFocus = (e) => {
|
||||
const el = find(e.target);
|
||||
if (!el || !el.matches(':focus-visible')) return; // only keyboard focus, not a mouse click
|
||||
const text = tipFor(el);
|
||||
if (text) { window.clearTimeout(timer.current); show(el, text); }
|
||||
};
|
||||
const onKey = (e) => { if (e.key === 'Escape') hide(); };
|
||||
|
||||
document.addEventListener('pointerover', onOver);
|
||||
document.addEventListener('pointerout', onOut);
|
||||
document.addEventListener('pointerdown', hide, true);
|
||||
document.addEventListener('focusin', onFocus);
|
||||
document.addEventListener('focusout', hide);
|
||||
document.addEventListener('keydown', onKey);
|
||||
window.addEventListener('scroll', hide, true);
|
||||
window.addEventListener('resize', hide);
|
||||
return () => {
|
||||
window.clearTimeout(timer.current);
|
||||
document.removeEventListener('pointerover', onOver);
|
||||
document.removeEventListener('pointerout', onOut);
|
||||
document.removeEventListener('pointerdown', hide, true);
|
||||
document.removeEventListener('focusin', onFocus);
|
||||
document.removeEventListener('focusout', hide);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
window.removeEventListener('scroll', hide, true);
|
||||
window.removeEventListener('resize', hide);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!tip) return null;
|
||||
// Centred over the control, kept inside the window, and flipped below it when there's no room above
|
||||
const width = box.current?.offsetWidth ?? 0;
|
||||
const left = Math.max(8, Math.min(tip.x - width / 2, window.innerWidth - width - 8));
|
||||
const flip = tip.y < 40;
|
||||
return (
|
||||
<div
|
||||
ref={box} id="app-tooltip" role="tooltip" style={{ ...themeVars(theme), left, top: flip ? tip.bottom + 8 : tip.y - 8, transform: flip ? 'none' : 'translateY(-100%)' }}
|
||||
className="pointer-events-none fixed z-[120] max-w-[16rem] rounded-lg bg-[var(--r-text)] px-2.5 py-1.5 font-sans text-xs leading-snug text-[var(--r-bg)] shadow-lg"
|
||||
>
|
||||
{tip.text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import { vi, beforeEach, afterEach, test, expect } from 'vitest';
|
||||
import Tooltips, { tipFor } from './Tooltips.jsx';
|
||||
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
const over = (el) => act(() => { fireEvent.pointerOver(el); vi.advanceTimersByTime(600); });
|
||||
|
||||
function setup() {
|
||||
render(
|
||||
<>
|
||||
<button aria-label="Search" data-tip="Search (/)">🔍</button>
|
||||
<button aria-label="Close">×</button>
|
||||
<button title="Out a level (Shift+Tab)">←</button>
|
||||
<button aria-label="Save changes">Save changes</button>
|
||||
<button disabled aria-label="Nothing">·</button>
|
||||
<Tooltips theme="paper" />
|
||||
</>,
|
||||
);
|
||||
}
|
||||
|
||||
test('shows a data-tip after a short pause, and hides when the pointer leaves', () => {
|
||||
setup();
|
||||
const btn = screen.getByLabelText('Search');
|
||||
over(btn);
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent('Search (/)');
|
||||
expect(btn).toHaveAttribute('aria-describedby', 'app-tooltip');
|
||||
act(() => { fireEvent.pointerOut(btn); });
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
expect(btn).not.toHaveAttribute('aria-describedby');
|
||||
});
|
||||
|
||||
test('does not appear before the pause', () => {
|
||||
setup();
|
||||
act(() => { fireEvent.pointerOver(screen.getByLabelText('Search')); vi.advanceTimersByTime(100); });
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('an icon-only button shows its label', () => {
|
||||
setup();
|
||||
over(screen.getByLabelText('Close'));
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent('Close');
|
||||
});
|
||||
|
||||
test('a title is shown in the app style and the browser\'s own tooltip is turned off', () => {
|
||||
setup();
|
||||
const btn = screen.getByText('←');
|
||||
over(btn);
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent('Out a level (Shift+Tab)');
|
||||
expect(btn).not.toHaveAttribute('title');
|
||||
expect(btn).toHaveAttribute('aria-label', 'Out a level (Shift+Tab)'); // a screen reader still gets its name
|
||||
});
|
||||
|
||||
test('a button that already says what it is gets no tooltip, and neither does a disabled one', () => {
|
||||
setup();
|
||||
over(screen.getByText('Save changes'));
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
over(screen.getByLabelText('Nothing'));
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('clicking, scrolling or Escape dismisses it', () => {
|
||||
setup();
|
||||
const btn = screen.getByLabelText('Search');
|
||||
over(btn);
|
||||
act(() => { fireEvent.pointerDown(btn); });
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
over(btn);
|
||||
act(() => { fireEvent.keyDown(document, { key: 'Escape' }); });
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
over(btn);
|
||||
act(() => { fireEvent.scroll(window); });
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('nothing shows on a device without hover (touch)', () => {
|
||||
vi.stubGlobal('matchMedia', () => ({ matches: false }));
|
||||
setup();
|
||||
over(screen.getByLabelText('Search'));
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test('tipFor ignores things that are not elements', () => {
|
||||
expect(tipFor(null)).toBe('');
|
||||
expect(tipFor(document.createTextNode('x'))).toBe('');
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// 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 { useEffect, 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';
|
||||
@@ -8,9 +8,11 @@ import { COLOR_NAMES, COLOR_SUGGESTIONS, HIGHLIGHT_COLORS, colorTitle } from '..
|
||||
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,
|
||||
open, onClose, initialTab = 'highlights', bookmarks, highlights, colorLabels, onSetLabel, onJump, onRemoveBookmark, onRemoveHighlight,
|
||||
}) {
|
||||
const [tab, setTab] = useState('highlights');
|
||||
const [tab, setTab] = useState(initialTab);
|
||||
// Opening from the Highlights button shows highlights; from the Bookmarks button, bookmarks
|
||||
useEffect(() => { if (open) setTab(initialTab); }, [open, initialTab]);
|
||||
const [filter, setFilter] = useState(null); // a highlight colour, or null for all
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
// Top and bottom chrome for the reader. Everything stays one tap away; focus
|
||||
// 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, HighlighterIcon } from './icons.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';
|
||||
|
||||
export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks, bookmarkCount, onSettings, onDoneDrawing }) {
|
||||
export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks, bookmarkCount, onHighlights, highlightCount, onCompare, compareActive, onSettings, onDoneDrawing }) {
|
||||
return (
|
||||
<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">
|
||||
<button type="button" onClick={onBack} className="flex h-9 items-center gap-0.5 rounded-lg pl-1 pr-2 text-[var(--r-sub)] transition hover:bg-black/5" aria-label="Back to home"><ChevronLeft /><span className="hidden text-xs sm:inline">Home</span></button>
|
||||
<button type="button" onClick={onBack} className="flex h-9 items-center gap-0.5 rounded-lg pl-1 pr-2 text-[var(--r-sub)] transition hover:bg-black/5" aria-label="Back to home" data-tip="Back to home"><ChevronLeft /><span className="hidden text-xs sm:inline">Home</span></button>
|
||||
<button type="button" onClick={onOpenNavigator}
|
||||
className="mx-auto flex min-w-0 items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium text-[var(--r-text)] hover:bg-black/5">
|
||||
className="mx-auto flex min-w-0 items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium text-[var(--r-text)] hover:bg-black/5" data-tip="Go to a book and chapter (G)">
|
||||
<span className="truncate">{title}</span><ChevronDown />
|
||||
</button>
|
||||
{onDoneDrawing ? (
|
||||
@@ -21,11 +21,15 @@ export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks,
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" onClick={onSearch} className={iconBtn} aria-label="Search"><SearchIcon /></button>
|
||||
<button type="button" onClick={onBookmarks} className={`${iconBtn} relative ${bookmarkCount ? 'text-[var(--r-accent)]' : ''}`} aria-label="Bookmarks">
|
||||
<button type="button" onClick={onSearch} className={iconBtn} aria-label="Search" data-tip="Search (/)"><SearchIcon /></button>
|
||||
<button type="button" onClick={onHighlights} className={`${iconBtn} ${highlightCount ? 'text-[var(--r-accent)]' : ''}`} aria-label="Highlights" data-tip="Your highlights, by colour (H)">
|
||||
<HighlighterIcon />
|
||||
</button>
|
||||
<button type="button" onClick={onBookmarks} className={`${iconBtn} relative ${bookmarkCount ? 'text-[var(--r-accent)]' : ''}`} aria-label="Bookmarks" data-tip="Your bookmarks">
|
||||
<BookmarkIcon filled={bookmarkCount > 0} />
|
||||
</button>
|
||||
<button type="button" onClick={onSettings} className={`${iconBtn} w-10 text-sm font-medium`} aria-label="Reading settings">Aa</button>
|
||||
<button type="button" onClick={onCompare} className={`${iconBtn} ${compareActive ? 'text-[var(--r-accent)]' : ''}`} aria-label="Compare translations" aria-pressed={!!compareActive} data-tip="Compare translations (C)"><CompareIcon /></button>
|
||||
<button type="button" onClick={onSettings} className={`${iconBtn} w-10 text-sm font-medium`} aria-label="Reading settings" data-tip="Text size, typeface and theme (,)">Aa</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -41,11 +45,11 @@ export function BottomBar({
|
||||
return (
|
||||
<div className="fixed inset-x-0 bottom-0 z-30 border-t border-[var(--r-line)] bg-[var(--r-bar)] pb-[env(safe-area-inset-bottom)] font-sans">
|
||||
<div className="mx-auto flex h-14 max-w-4xl items-center gap-0.5 px-2">
|
||||
<button type="button" onClick={onPrev} disabled={prevDisabled} className={iconBtn} aria-label="Previous chapter"><ChevronLeft /></button>
|
||||
<button type="button" onClick={onAudio} className={`${iconBtn} ${audioActive ? active : ''}`} aria-label="Listen"><HeadphonesIcon /></button>
|
||||
<button type="button" onClick={onPen} className={iconBtn} aria-label="Draw notes"><PenIcon /></button>
|
||||
<button type="button" onClick={onCrossRefs} className={`${iconBtn} ${crossRefsOn ? active : ''}`} aria-label="Show cross-references under verses"><LinkIcon /></button>
|
||||
<button type="button" onClick={onFocus} className={iconBtn} aria-label="Hide controls"><FocusIcon /></button>
|
||||
<button type="button" onClick={onPrev} disabled={prevDisabled} className={iconBtn} aria-label="Previous chapter" data-tip="Previous chapter (←)"><ChevronLeft /></button>
|
||||
<button type="button" onClick={onAudio} className={`${iconBtn} ${audioActive ? active : ''}`} aria-label="Listen" data-tip="Listen to this chapter"><HeadphonesIcon /></button>
|
||||
<button type="button" onClick={onPen} className={iconBtn} aria-label="Draw notes" data-tip="Draw notes on this page"><PenIcon /></button>
|
||||
<button type="button" onClick={onCrossRefs} className={`${iconBtn} ${crossRefsOn ? active : ''}`} aria-label="Show cross-references under verses" data-tip="Show cross-references under each verse"><LinkIcon /></button>
|
||||
<button type="button" onClick={onFocus} className={iconBtn} aria-label="Hide controls" data-tip="Hide the controls (F)"><FocusIcon /></button>
|
||||
<div className="mx-auto" />
|
||||
{primary && (
|
||||
<button type="button" onClick={primary.onClick}
|
||||
@@ -53,7 +57,7 @@ export function BottomBar({
|
||||
{primary.done ? null : <CheckIcon />}{primary.label}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={onNext} disabled={nextDisabled} className={iconBtn} aria-label="Next chapter"><ChevronRight /></button>
|
||||
<button type="button" onClick={onNext} disabled={nextDisabled} className={iconBtn} aria-label="Next chapter" data-tip="Next chapter (→)"><ChevronRight /></button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// 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.
|
||||
import { useMemo } from 'react';
|
||||
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 = {
|
||||
@@ -13,10 +16,18 @@ 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({
|
||||
title, translation, translationId, notice, verses, loading, error, layout, fontSize, wide,
|
||||
font = 'serif', spacing = 'normal', showVerseNumbers = true, parallel = null,
|
||||
font = 'serif', spacing = 'normal', showVerseNumbers = true, parallel = null, showDiff = true,
|
||||
bookmarks, bookKey, chapter, selectedVerse, onSelectVerse, renderVerseText, searchLower,
|
||||
crossRefs, showCrossRefs, onSelectionEnd, footer,
|
||||
crossRefs, showCrossRefs, onOpenCrossRef, 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 sel = window.getSelection();
|
||||
if (sel && !sel.isCollapsed && sel.toString().trim()) return; // user is selecting text, not tapping
|
||||
@@ -45,7 +56,11 @@ export default function ReaderText({
|
||||
const chips = (v) => showCrossRefs && crossRefs?.[v.number]?.length > 0 && (
|
||||
<span className="ml-1 inline-flex flex-wrap gap-1 align-baseline font-sans">
|
||||
{crossRefs[v.number].slice(0, 4).map((ref, i) => (
|
||||
<span key={i} className="rounded-full border border-[var(--r-line)] px-1.5 py-px text-[10px] text-[var(--r-sub)]">{formatCrossRef(ref)}</span>
|
||||
// Opens that passage in the side panel; stopPropagation so the verse itself isn't also toggled
|
||||
<button key={i} type="button" onClick={(e) => { e.stopPropagation(); onOpenCrossRef?.(v.number, ref); }}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className="rounded-full border border-[var(--r-line)] px-1.5 py-px text-[10px] text-[var(--r-sub)] hover:border-[var(--r-accent)] hover:text-[var(--r-accent)]"
|
||||
data-tip={`Read ${formatCrossRef(ref)} in the side panel`}>{formatCrossRef(ref)}</button>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
@@ -92,6 +107,23 @@ export default function ReaderText({
|
||||
|
||||
{!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>
|
||||
@@ -99,12 +131,14 @@ export default function ReaderText({
|
||||
<div key={row.number} className="mb-4 grid gap-x-10 gap-y-1 md:grid-cols-2">
|
||||
<p>
|
||||
{row.first
|
||||
? <span {...verseProps(row.first)}>{num(row.first.number)}{renderVerseText(row.first.number, row.first.text, searchLower)}{chips(row.first)}</span>
|
||||
? <span {...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)}{row.second.text}</> : parallel.loading ? <span className="font-sans text-xs" aria-label="Loading">…</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>
|
||||
))}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
@@ -2,7 +2,6 @@
|
||||
// to read beside the first.
|
||||
import Sheet from './Sheet.jsx';
|
||||
import { READER_THEMES } from './readerThemes.js';
|
||||
import { READER_TRANSLATIONS } from '../../utils/bibleText.js';
|
||||
|
||||
const SIZES = [['S', 0.875], ['M', 1], ['L', 1.125], ['XL', 1.25], ['XXL', 1.5]];
|
||||
|
||||
@@ -22,7 +21,6 @@ function Segmented({ options, value, onChange, label }) {
|
||||
export default function SettingsSheet({
|
||||
open, onClose, fontSize, onFontSize, theme, onTheme, layout, onLayout, wide, onWide,
|
||||
font, onFont, spacing, onSpacing, verseNumbers, onVerseNumbers,
|
||||
translation, parallel, onParallel,
|
||||
}) {
|
||||
const row = 'mb-4';
|
||||
const label = 'mb-1.5 block text-xs text-[var(--r-sub)]';
|
||||
@@ -56,18 +54,6 @@ export default function SettingsSheet({
|
||||
<span className={label}>Width</span>
|
||||
<Segmented label="Reading width" options={[[false, 'Column'], [true, 'Two columns']]} value={wide} onChange={onWide} />
|
||||
</div>
|
||||
<div className={row}>
|
||||
<span className={label}>Read beside another Bible</span>
|
||||
<div role="group" aria-label="Second translation" className="flex flex-wrap gap-1.5">
|
||||
{[{ id: '', name: 'Off' }, ...READER_TRANSLATIONS.filter((t) => t.id !== translation)].map((t) => (
|
||||
<button key={t.id || 'off'} type="button" onClick={() => onParallel(t.id)} aria-pressed={parallel === t.id} title={t.name}
|
||||
className={`rounded-full border px-3 py-1 text-xs font-medium ${parallel === t.id ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
|
||||
{t.id || 'Off'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11px] text-[var(--r-sub)]">Shows both side by side on wider screens, and stacked on a phone.</p>
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { CloseIcon } from './icons.jsx';
|
||||
|
||||
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 }) {
|
||||
export default function Sheet({ open, onClose, title, label, headerRight, modal = true, pane = false, panelRef, edge, children }) {
|
||||
const dialogRef = useRef(null);
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
@@ -41,15 +41,16 @@ export default function Sheet({ open, onClose, title, label, headerRight, modal
|
||||
<>
|
||||
{modal && <div className="fixed inset-0 z-40 bg-black/30" onClick={onClose} aria-hidden="true" />}
|
||||
<div
|
||||
ref={dialogRef}
|
||||
ref={(el) => { dialogRef.current = el; if (panelRef) panelRef.current = el; }}
|
||||
role="dialog"
|
||||
aria-modal={modal ? 'true' : undefined}
|
||||
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-[var(--reader-pane,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'
|
||||
}`}
|
||||
>
|
||||
{edge}
|
||||
<div className="mx-auto mt-2 h-1 w-9 shrink-0 rounded-full bg-[var(--r-line)] lg:hidden" />
|
||||
<div className="flex items-center justify-between gap-2 px-4 pb-2 pt-2">
|
||||
<div className="min-w-0 truncate text-sm font-medium">{title}</div>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
// The single home for per-verse tools: original words, cross-references,
|
||||
// commentary, and people & places, plus bookmark / copy.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Sheet from './Sheet.jsx';
|
||||
import { CommentaryTab, PeoplePlacesTab } from '../StudyPanel.jsx';
|
||||
import { BookmarkIcon, CopyIcon } from '../../utils/bibleUtils.jsx';
|
||||
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';
|
||||
import { HIGHLIGHT_COLORS, colorTitle } from '../../utils/highlightColors.js';
|
||||
|
||||
const TABS = [['commentary', 'Commentary'], ['compare', 'Compare'], ['original', 'Original'], ['refs', 'Cross-refs'], ['people', 'People & places']];
|
||||
|
||||
@@ -35,18 +38,59 @@ function OriginalTab({ words }) {
|
||||
);
|
||||
}
|
||||
|
||||
function RefsTab({ refs, loading, onJump }) {
|
||||
// A cross-referenced passage shown in the panel: its words in the translation being read, without
|
||||
// leaving the chapter. "Open chapter" goes there when you want more.
|
||||
function RefPreview({ refItem, translation, onOpen, onClose }) {
|
||||
const [state, setState] = useState({ key: '', verses: [], error: '' });
|
||||
const key = `${translation}/${refItem.book}/${refItem.chapter}`;
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
fetchChapter(translation, refItem.book, refItem.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 that passage.' }); });
|
||||
return () => controller.abort();
|
||||
}, [key]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const end = refItem.endVerse && refItem.endVerse > refItem.verse ? refItem.endVerse : refItem.verse;
|
||||
const picked = state.key === key ? state.verses.filter((v) => v.number >= refItem.verse && v.number <= end) : [];
|
||||
const loading = state.key !== key;
|
||||
return (
|
||||
<div className="mb-3 rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-3" role="region" aria-label={`Cross-reference ${formatCrossRef(refItem)}`}>
|
||||
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium">{formatCrossRef(refItem)} <span className="text-xs font-normal text-[var(--r-sub)]">{translation}</span></p>
|
||||
<button type="button" onClick={onClose} aria-label="Close cross-reference" className="rounded p-1 text-[var(--r-sub)] hover:bg-black/5">×</button>
|
||||
</div>
|
||||
{loading ? <p className="text-sm text-[var(--r-sub)]">Loading…</p>
|
||||
: state.error ? <p className="text-sm text-[var(--r-danger,#b42318)]">{state.error}</p>
|
||||
: picked.length === 0 ? <p className="text-sm text-[var(--r-sub)]">Those verses weren’t found.</p>
|
||||
: (
|
||||
<p className="font-serif text-[15px] leading-relaxed">
|
||||
{picked.map((v) => <span key={v.number}>{picked.length > 1 && <sup className="mr-0.5 font-sans text-[0.65em] text-[var(--r-sub)]">{v.number}</sup>}{v.text}{' '}</span>)}
|
||||
</p>
|
||||
)}
|
||||
<button type="button" onClick={() => onOpen(refItem.book, refItem.chapter, refItem.verse)} className="mt-2 text-xs font-medium text-[var(--r-accent)] hover:underline">Open chapter</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sameRef = (a, b) => a && b && a.book === b.book && a.chapter === b.chapter && a.verse === b.verse && (a.endVerse ?? a.verse) === (b.endVerse ?? b.verse);
|
||||
|
||||
function RefsTab({ refs, loading, active, onPick, preview }) {
|
||||
if (loading && !refs) return <p className="text-sm text-[var(--r-sub)]">Loading…</p>;
|
||||
if (!refs?.length) return <p className="text-sm text-[var(--r-sub)]">No cross-references for this verse.</p>;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{refs.slice(0, 20).map((ref, i) => (
|
||||
<button key={i} type="button" onClick={() => onJump(ref.book, ref.chapter, ref.verse)}
|
||||
className="rounded-full border border-[var(--r-line)] px-2.5 py-1 text-xs hover:bg-black/5">
|
||||
{formatCrossRef(ref)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
{preview}
|
||||
<p className="mb-1.5 text-[11px] text-[var(--r-sub)]">Tap one to read it here.</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{refs.slice(0, 20).map((ref, i) => (
|
||||
<button key={i} type="button" onClick={() => onPick(ref)} aria-pressed={sameRef(active, ref)}
|
||||
className={`rounded-full border px-2.5 py-1 text-xs ${sameRef(active, ref) ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] hover:bg-black/5'}`}>
|
||||
{formatCrossRef(ref)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,30 +112,56 @@ function CompareTab({ book, chapter, verse, current }) {
|
||||
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 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">{t.id}{t.id === current ? ' · reading' : ''}</p>
|
||||
<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">{row.text}</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)]">Copyright notices for each translation appear at the foot of the chapter when you read it.</p>
|
||||
<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({
|
||||
verse, book, bookName, chapter, translation, words, crossRefs, crossRefsLoading, onLoadCrossRefs,
|
||||
bookmarkColor, onToggleBookmark, onCycleColor, onJump, onClose,
|
||||
bookmarkColor, onToggleBookmark, onCycleColor, onJump, onClose, request,
|
||||
verseHighlights = [], colorLabels = {}, onHighlightVerse, onClearVerseHighlights, panelRef, resizeHandle,
|
||||
}) {
|
||||
const [tab, setTab] = useState('commentary');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [preview, setPreview] = useState(null); // the cross-reference being read in the panel
|
||||
const appliedRequest = useRef(0);
|
||||
// Leaving this verse closes the preview; a cross-reference tapped in the text opens it here. (Tracks the
|
||||
// previous verse so React's double-run of effects in development can't clear a preview it just opened.)
|
||||
const previousVerse = useRef(null);
|
||||
useEffect(() => {
|
||||
const verseChanged = previousVerse.current !== verse?.number;
|
||||
previousVerse.current = verse?.number;
|
||||
if (request && request.verse === verse?.number && request.n !== appliedRequest.current) {
|
||||
appliedRequest.current = request.n;
|
||||
setTab('refs');
|
||||
setPreview(request.ref);
|
||||
} else if (verseChanged) {
|
||||
setPreview(null);
|
||||
}
|
||||
}, [verse?.number, request?.n]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
useEffect(() => { if (verse && tab === 'refs') onLoadCrossRefs(); }, [verse?.number, tab]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
useEffect(() => { setCopied(false); }, [verse?.number]);
|
||||
if (!verse) return null;
|
||||
@@ -105,7 +175,7 @@ export default function VerseSheet({
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open modal={false} pane onClose={onClose}
|
||||
open modal={false} pane onClose={onClose} panelRef={panelRef} edge={resizeHandle}
|
||||
title={`${bookName} ${chapter}:${verse.number}`}
|
||||
headerRight={(
|
||||
<>
|
||||
@@ -123,6 +193,27 @@ export default function VerseSheet({
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{onHighlightVerse && (
|
||||
// One-tap highlighting of the whole verse (to mark just some words, select them in the text)
|
||||
<div className="mb-3 flex flex-wrap items-center gap-1.5" role="group" aria-label="Highlight this verse">
|
||||
<span className="mr-1 text-xs text-[var(--r-sub)]">Highlight</span>
|
||||
{HIGHLIGHT_COLORS.map((color) => {
|
||||
const on = verseHighlights.some((h) => h.color === color && h.startOffset === 0 && h.endOffset >= verse.text.length);
|
||||
return (
|
||||
<button key={color} type="button" onClick={() => onHighlightVerse(color)} aria-pressed={on}
|
||||
aria-label={`Highlight verse ${colorTitle(color, colorLabels).toLowerCase()}`} data-tip={`${colorTitle(color, colorLabels)}${on ? ' — tap to remove' : ''}`}
|
||||
className={`h-6 w-6 rounded-full border-2 transition hover:scale-110 ${on ? 'border-[var(--r-text)]' : 'border-transparent ring-1 ring-black/10'}`}
|
||||
style={{ backgroundColor: color }}>
|
||||
{on && <span className="text-[10px] leading-none text-[#2b2620]" aria-hidden="true">✓</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{verseHighlights.length > 0 && (
|
||||
<button type="button" onClick={onClearVerseHighlights} className="ml-1 text-xs text-[var(--r-sub)] underline hover:text-[var(--r-text)]">Clear</button>
|
||||
)}
|
||||
<span className="basis-full text-[11px] text-[var(--r-sub)]">Or select words in the text to highlight just those.</span>
|
||||
</div>
|
||||
)}
|
||||
<div role="tablist" aria-label="About this verse" className="mb-3 flex gap-1.5 overflow-x-auto pb-1">
|
||||
{TABS.map(([id, label]) => (
|
||||
<button key={id} type="button" role="tab" aria-selected={tab === id} onClick={() => setTab(id)}
|
||||
@@ -134,7 +225,10 @@ export default function VerseSheet({
|
||||
{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 === 'refs' && <RefsTab refs={crossRefs} loading={crossRefsLoading} onJump={onJump} />}
|
||||
{tab === 'refs' && (
|
||||
<RefsTab refs={crossRefs} loading={crossRefsLoading} active={preview} onPick={setPreview}
|
||||
preview={preview && <RefPreview refItem={preview} translation={translation} onOpen={onJump} onClose={() => setPreview(null)} />} />
|
||||
)}
|
||||
{tab === 'people' && <PeoplePlacesTab book={book} chapter={chapter} verse={verse.number} />}
|
||||
</Sheet>
|
||||
);
|
||||
|
||||
@@ -11,3 +11,5 @@ 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 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 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>;
|
||||
export const HighlighterIcon = () => <svg {...base}><path d="M12.5 3.5l4 4-6.8 6.8-4-4 6.8-6.8z" /><path d="M5.7 10.3l-1.6 4.4 1.2 1.2 4.4-1.6" /><path d="M3 17.2h6" /></svg>;
|
||||
@@ -83,7 +83,7 @@ export default function PageShell({ title, onBack, actions, active = null, maxWi
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{openPalette && (
|
||||
<button type="button" onClick={openPalette} aria-label="Search and go to"
|
||||
<button type="button" onClick={openPalette} aria-label="Search and go to" data-tip="Search studies, notes and passages (⌘K)"
|
||||
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>
|
||||
|
||||
@@ -103,6 +103,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
|
||||
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'
|
||||
const [startPagePref, setStartPagePrefState] = useState(() => {
|
||||
@@ -532,6 +533,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
|
||||
readerSpacing, setReaderSpacing,
|
||||
readerVerseNumbers: readerVerseNumbers === 'on', setReaderVerseNumbers: (on) => setReaderVerseNumbersRaw(on ? 'on' : 'off'),
|
||||
readerParallel, setReaderParallel,
|
||||
readerShowDiff: readerShowDiff === 'on', setReaderShowDiff: (on) => setReaderShowDiffRaw(on ? 'on' : 'off'),
|
||||
planTranslationPref, setPlanTranslationPref,
|
||||
startPagePref, setStartPagePref,
|
||||
readerBookmarks, setReaderBookmarks,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -6,7 +6,8 @@ const STEP = 24;
|
||||
const BIG_STEP = 96;
|
||||
|
||||
// containerRef: the element the panel sits at the right edge of (for measuring and clamping).
|
||||
export function useResizableWidth({ storageKey, min = 320, maxAbsolute = 760, fallback = 448, reserve = 600, containerRef }) {
|
||||
// cssVar: the custom property on the container that carries the width while dragging.
|
||||
export function useResizableWidth({ storageKey, min = 320, maxAbsolute = 760, fallback = 448, reserve = 600, containerRef, cssVar = '--study-right' }) {
|
||||
const maxFor = () => {
|
||||
const w = containerRef.current?.getBoundingClientRect().width ?? 0;
|
||||
return w > 0 ? Math.max(min, Math.min(maxAbsolute, w - reserve)) : maxAbsolute;
|
||||
@@ -51,7 +52,7 @@ export function useResizableWidth({ storageKey, min = 320, maxAbsolute = 760, fa
|
||||
const right = containerRef.current?.getBoundingClientRect().right ?? window.innerWidth;
|
||||
const next = clamp(right - e.clientX);
|
||||
dragRef.current.width = next;
|
||||
containerRef.current?.style.setProperty('--study-right', `${next}px`); // smooth: no React render per move
|
||||
containerRef.current?.style.setProperty(cssVar, `${next}px`); // smooth: no React render per move
|
||||
},
|
||||
onPointerUp: (e) => {
|
||||
if (!dragRef.current) return;
|
||||
@@ -65,9 +66,9 @@ export function useResizableWidth({ storageKey, min = 320, maxAbsolute = 760, fa
|
||||
const step = e.shiftKey ? BIG_STEP : STEP;
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); commit(clamp(current(panelRef.current) + step)); }
|
||||
else if (e.key === 'ArrowRight') { e.preventDefault(); commit(clamp(current(panelRef.current) - step)); }
|
||||
else if (e.key === 'Home' || e.key === 'Escape') { e.preventDefault(); containerRef.current?.style.removeProperty('--study-right'); commit(null); }
|
||||
else if (e.key === 'Home' || e.key === 'Escape') { e.preventDefault(); containerRef.current?.style.removeProperty(cssVar); commit(null); }
|
||||
},
|
||||
onDoubleClick: () => { containerRef.current?.style.removeProperty('--study-right'); commit(null); },
|
||||
onDoubleClick: () => { containerRef.current?.style.removeProperty(cssVar); commit(null); },
|
||||
title: 'Drag to resize · double-click to reset',
|
||||
});
|
||||
|
||||
|
||||
@@ -79,3 +79,12 @@ test('the handle describes itself for assistive technology', () => {
|
||||
const props = setup().handle();
|
||||
expect(props).toMatchObject({ role: 'separator', 'aria-orientation': 'vertical', 'aria-label': 'Resize side panel', tabIndex: 0, 'aria-valuenow': 448 });
|
||||
});
|
||||
|
||||
test('a different panel can name its own CSS variable', () => {
|
||||
const h = setup({ cssVar: '--reader-pane' });
|
||||
act(() => { down(h); move(h, 1000); });
|
||||
expect(h.style.setProperty).toHaveBeenLastCalledWith('--reader-pane', '400px');
|
||||
act(() => up(h));
|
||||
act(() => { h.handle().onDoubleClick(); });
|
||||
expect(h.style.removeProperty).toHaveBeenLastCalledWith('--reader-pane');
|
||||
});
|
||||
@@ -82,3 +82,10 @@ select {
|
||||
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;
|
||||
}
|
||||
+184
-77
@@ -1,8 +1,91 @@
|
||||
import { useState } from 'react';
|
||||
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';
|
||||
|
||||
// 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() {
|
||||
const { signIn, readerTheme } = useApp();
|
||||
const shell = { style: themeVars(readerTheme) };
|
||||
@@ -53,8 +136,8 @@ export default function AuthPage() {
|
||||
return (
|
||||
<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">
|
||||
<p className="text-sm uppercase tracking-[0.24em] text-[var(--r-sub)]">Bible Study Project</p>
|
||||
<h1 className="mt-2 text-xl font-medium text-[var(--r-text)]">Two-factor verification</h1>
|
||||
<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-3 text-xl font-medium text-[var(--r-text)]">Two-factor verification</h1>
|
||||
<p className="mt-2 text-sm text-[var(--r-sub)]">
|
||||
{authMfaUseBackup
|
||||
? 'Enter one of your saved backup codes.'
|
||||
@@ -109,83 +192,107 @@ export default function AuthPage() {
|
||||
}
|
||||
|
||||
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 className="grid w-full max-w-5xl items-center gap-16 lg:grid-cols-2">
|
||||
{/* Branding / feature panel — hidden on small screens to keep the form front and center there */}
|
||||
<div className="hidden lg:block">
|
||||
<p className="text-sm uppercase tracking-[0.24em] text-[var(--r-sub)]">Bible Study Project</p>
|
||||
<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>
|
||||
<ul className="mt-8 space-y-3 text-sm text-[var(--r-sub)]">
|
||||
<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 & 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 {...shell} className="min-h-dvh bg-[var(--r-bg)] text-[var(--r-text)]">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-5 pt-5 sm:px-8">
|
||||
<p className="flex items-center gap-2.5 text-sm font-medium">
|
||||
<img src="/logo.svg" alt="" className="h-7 w-7" />
|
||||
<span>Bible Study Project</span>
|
||||
</p>
|
||||
<ThemeSwitch />
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{/* Sign in / create account. First on a phone; on a wide screen it stays in view beside the tour. */}
|
||||
<div className="lg:order-2">
|
||||
<div className="lg:sticky lg:top-10">
|
||||
<div className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-7 shadow-sm sm:p-8">
|
||||
<h2 className="text-xl font-medium">{authMode === 'login' ? 'Sign in' : 'Create an account'}</h2>
|
||||
<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">
|
||||
<label className="block text-sm">
|
||||
Email
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
value={authForm.email}
|
||||
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.5 text-sm text-[var(--r-text)]"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete={authMode === 'login' ? 'current-password' : 'new-password'}
|
||||
value={authForm.password}
|
||||
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.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>
|
||||
{authError && <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{authError}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={authBusy}
|
||||
className="w-full rounded-full bg-[var(--r-accent)] px-4 py-2.5 text-sm font-medium text-[var(--r-on)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{authBusy ? 'Please wait…' : authMode === 'login' ? 'Sign in' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAuthMode((m) => (m === 'login' ? 'register' : 'login'));
|
||||
setAuthError('');
|
||||
}}
|
||||
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'}
|
||||
</button>
|
||||
</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>
|
||||
|
||||
{/* Sign in / register form */}
|
||||
<div className="flex justify-center lg:justify-start">
|
||||
<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)] lg:hidden">Bible Study Project</p>
|
||||
<h1 className="mt-2 text-xl font-medium text-[var(--r-text)]">
|
||||
{authMode === 'login' ? 'Sign in' : 'Create an account'}
|
||||
{/* 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>
|
||||
<form onSubmit={submitAuth} className="mt-6 space-y-4">
|
||||
<label className="block text-sm">
|
||||
Email
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
value={authForm.email}
|
||||
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)]"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-[var(--r-text)]">
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete={authMode === 'login' ? 'current-password' : 'new-password'}
|
||||
value={authForm.password}
|
||||
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)]"
|
||||
/>
|
||||
</label>
|
||||
{authError && <p className="text-sm text-[var(--r-danger,#b42318)]">{authError}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={authBusy}
|
||||
className="w-full rounded-full bg-[var(--r-accent)] px-4 py-2.5 text-sm font-medium text-[var(--r-on)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{authBusy ? 'Please wait…' : authMode === 'login' ? 'Sign in' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAuthMode((m) => (m === 'login' ? 'register' : 'login'));
|
||||
setAuthError('');
|
||||
}}
|
||||
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'}
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
+206
-78
@@ -1,21 +1,29 @@
|
||||
// The Outline stage: the shape of the message as a real outline — main points (I.), sub-points (A.),
|
||||
// and deeper (1., a.) — written after studying the chunks. Each point can carry a passage and link to
|
||||
// the chunk it comes from. Everything is kept on the project and goes out with the exports.
|
||||
// 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 {
|
||||
addAfter, addChild, addRoot, countNodes, flatten, getNode, indent, insertAt, locate, moveNode, newNode,
|
||||
outdent, outlineToText, removeNode, seedFromChunks, updateNode,
|
||||
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 PointText({ value, onChange, ...rest }) {
|
||||
function AutoText({ value, onChange, className, ...rest }) {
|
||||
const ref = useRef(null);
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
@@ -23,23 +31,26 @@ function PointText({ value, onChange, ...rest }) {
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
}, [value]);
|
||||
return (
|
||||
<textarea ref={ref} rows={1} value={value} onChange={(e) => onChange(e.target.value)}
|
||||
className="block w-full resize-none overflow-hidden rounded-lg border border-transparent bg-transparent px-2 py-1.5 text-[15px] leading-snug text-[var(--r-text)] placeholder:text-[var(--r-sub)] hover:border-[var(--r-line)] focus:border-[var(--r-line)] focus:bg-[var(--r-bg)] focus:outline-none"
|
||||
{...rest} />
|
||||
);
|
||||
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, allChunks, updateProject, setCurrentPage } = useApp();
|
||||
const { project, updateProject, setCurrentPage } = useApp();
|
||||
const { toast } = useToast();
|
||||
const tree = project?.outline ?? [];
|
||||
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 passage/chunk fields are showing
|
||||
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) => ({ ...current, outline: fn(current.outline ?? []) }));
|
||||
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;
|
||||
@@ -47,27 +58,27 @@ export default function OutlinePage() {
|
||||
setPendingFocus(null);
|
||||
}, [pendingFocus, tree]);
|
||||
|
||||
// The chunk choices, named by passage
|
||||
// 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, '–') });
|
||||
chunkChoices.push({ id: chunk.id, label: formatChunkReference(project, chapterIndex, chunk, '–'), count: countNodes(chunk.outline ?? []) });
|
||||
}));
|
||||
const unlinked = seedFromChunks(project, tree).length - tree.length;
|
||||
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 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 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);
|
||||
@@ -94,42 +105,82 @@ export default function OutlinePage() {
|
||||
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 addChunks = () => {
|
||||
edit((t) => seedFromChunks(project, t));
|
||||
toast(`Added ${unlinked} point${unlinked === 1 ? '' : 's'} from your chunks`);
|
||||
};
|
||||
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-5 flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-medium">Outline</h1>
|
||||
<p className="mt-1 text-sm text-[var(--r-sub)]">The shape of the message: main points, sub-points under them, and deeper if you need it.</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" onClick={addMain} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90">+ Main point</button>
|
||||
<button type="button" onClick={addChunks} disabled={unlinked === 0} className={btn} title="Adds a main point for each chunk that isn't in the outline yet">
|
||||
Add my chunks{unlinked > 0 ? ` (${unlinked})` : ''}
|
||||
</button>
|
||||
<button type="button" onClick={copy} disabled={tree.length === 0} className={btn}>Copy as text</button>
|
||||
</div>
|
||||
<div 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</p>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm text-[var(--r-sub)]">Start from your chunks to get a main point for each passage you studied, or start blank and write your own.</p>
|
||||
<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={addChunks} disabled={unlinked === 0} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50">Start from my chunks</button>
|
||||
<button type="button" onClick={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>
|
||||
@@ -137,54 +188,130 @@ export default function OutlinePage() {
|
||||
<ul aria-label="Outline" className="space-y-0.5">
|
||||
{rows.map(({ node, depth, label }, at) => {
|
||||
const hasChildren = node.children.length > 0;
|
||||
const showMeta = metaOpen.has(node.id) || (!!node.ref && false);
|
||||
const 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">
|
||||
<PointText
|
||||
value={node.text} data-outline-id={node.id}
|
||||
aria-label={`Point ${label}`}
|
||||
placeholder={depth === 0 ? 'Main point' : 'Sub-point'}
|
||||
onChange={(value) => edit((t) => updateNode(t, node.id, { text: value }))}
|
||||
<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)}
|
||||
/>
|
||||
{(node.ref || linked) && !showMeta && (
|
||||
<p className="px-2 text-xs text-[var(--r-sub)]">
|
||||
{node.ref}
|
||||
{linked && <>{node.ref ? ' · ' : ''}<button type="button" onClick={() => openChunk(node.chunkId)} className="text-[var(--r-accent)] hover:underline">Open chunk in Study</button></>}
|
||||
|
||||
{/* 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-1 grid gap-2 px-2 pt-1 sm:grid-cols-2">
|
||||
<label className="text-xs text-[var(--r-sub)]">Passage
|
||||
<input value={node.ref} onChange={(e) => edit((t) => updateNode(t, node.id, { ref: e.target.value }))} placeholder="e.g. Titus 1:5–9"
|
||||
className="mt-0.5 block w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1.5 text-sm text-[var(--r-text)]" />
|
||||
</label>
|
||||
<label className="text-xs text-[var(--r-sub)]">Linked chunk
|
||||
<select value={node.chunkId ?? ''} onChange={(e) => edit((t) => updateNode(t, node.id, {
|
||||
chunkId: e.target.value || null,
|
||||
...(e.target.value && !node.ref ? { ref: chunkChoices.find((c) => c.id === e.target.value)?.label ?? '' } : {}),
|
||||
}))}
|
||||
className="mt-0.5 block w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1.5 text-sm text-[var(--r-text)]">
|
||||
<option value="">None</option>
|
||||
{chunkChoices.map((c) => <option key={c.id} value={c.id}>{c.label}</option>)}
|
||||
</select>
|
||||
<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} out a level`} title="Out a level (Shift+Tab)" disabled={depth === 0} onClick={() => edit((t) => outdent(t, node.id))}>←</button>
|
||||
<button type="button" className={iconBtn} aria-label={`Move ${label} in a level`} title="In a level (Tab)" disabled={place === 0} onClick={() => edit((t) => indent(t, node.id))}>→</button>
|
||||
<button type="button" className={iconBtn} aria-label={`Move ${label} up`} title="Up (Alt+↑)" disabled={place === 0} onClick={() => edit((t) => moveNode(t, node.id, -1))}>↑</button>
|
||||
<button type="button" className={iconBtn} aria-label={`Move ${label} down`} title="Down (Alt+↓)" disabled={place === siblings.length - 1} onClick={() => edit((t) => moveNode(t, node.id, 1))}>↓</button>
|
||||
<button type="button" className={iconBtn} aria-label={`Add a sub-point under ${label}`} title="Add a sub-point" onClick={() => addSub(node.id)}>+</button>
|
||||
<button type="button" className={iconBtn} aria-label={`Passage and chunk for ${label}`} aria-expanded={showMeta} title="Passage and chunk" onClick={() => toggleMeta(node.id)}>⋯</button>
|
||||
<button type="button" className={iconBtn} 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>
|
||||
@@ -196,10 +323,11 @@ export default function OutlinePage() {
|
||||
|
||||
{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.
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { bookOptions } from '../utils/bibleUtils.jsx';
|
||||
import { getPlan, getPlanSchedule, findDayIndexForRef, formatDayReadings, nextUnreadDayIndex, computeStreak, nearestReadingDay } from '../utils/readingPlans.js';
|
||||
import DrawCanvas from './DrawCanvas.jsx';
|
||||
import { READER_TRANSLATIONS } from '../utils/bibleText.js';
|
||||
import { useResizableWidth } from '../hooks/useResizableWidth.js';
|
||||
import { useParallelChapter } from '../hooks/useParallelChapter.js';
|
||||
import { useOfflineSave, useOnline } from '../hooks/useOfflineSave.js';
|
||||
import { useBibleSearch } from '../hooks/useBibleSearch.js';
|
||||
@@ -15,6 +16,7 @@ import ReaderText from '../components/reader/ReaderText.jsx';
|
||||
import PlanStrip from '../components/reader/PlanStrip.jsx';
|
||||
import NavigatorSheet from '../components/reader/NavigatorSheet.jsx';
|
||||
import SettingsSheet from '../components/reader/SettingsSheet.jsx';
|
||||
import CompareSheet from '../components/reader/CompareSheet.jsx';
|
||||
import SearchSheet from '../components/reader/SearchSheet.jsx';
|
||||
import MarksSheet from '../components/reader/MarksSheet.jsx';
|
||||
import { useToast } from '../components/Toasts.jsx';
|
||||
@@ -47,6 +49,7 @@ export default function ReaderPage() {
|
||||
readerSpacing, setReaderSpacing,
|
||||
readerVerseNumbers, setReaderVerseNumbers,
|
||||
readerParallel, setReaderParallel,
|
||||
readerShowDiff, setReaderShowDiff,
|
||||
readerBookmarks,
|
||||
readerCrossRefs, setReaderCrossRefs,
|
||||
readerCrossRefsLoading,
|
||||
@@ -100,11 +103,16 @@ export default function ReaderPage() {
|
||||
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' | 'highlights' | 'audio'
|
||||
const [chromeVisible, setChromeVisible] = useState(true);
|
||||
const [readerDrawMode, setReaderDrawMode] = useState(false);
|
||||
const [selectionPicker, setSelectionPicker] = useState(null);
|
||||
const [xrefRequest, setXrefRequest] = useState(null); // a cross-reference tapped in the text: { verse, ref, n }
|
||||
const swipeTouchRef = useRef(null);
|
||||
// The side panel's width: drag its left edge (remembered on this device)
|
||||
const rootRef = useRef(null);
|
||||
const paneRef = useRef(null);
|
||||
const pane = useResizableWidth({ storageKey: 'reader-pane-width', containerRef: rootRef, min: 320, maxAbsolute: 800, fallback: 384, reserve: 460, cssVar: '--reader-pane' });
|
||||
const readerPageInkStrokes = readerInkByPage?.[`${readerBookAbbrev}_${readerChapter}`] ?? NO_STROKES;
|
||||
|
||||
|
||||
@@ -271,6 +279,8 @@ export default function ReaderPage() {
|
||||
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 === 'h' || e.key === 'H') { e.preventDefault(); openSheet('highlights'); }
|
||||
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);
|
||||
@@ -295,6 +305,18 @@ export default function ReaderPage() {
|
||||
removeTextHighlight(id);
|
||||
if (gone) toast('Highlight removed', { action: { label: 'Undo', onClick: () => restoreTextHighlight(gone) } });
|
||||
};
|
||||
// Highlighting a whole verse from the verse panel: tapping the same colour again takes it off.
|
||||
const highlightVerse = (color) => {
|
||||
if (!selectedVerse) return;
|
||||
const existing = verseHighlights.find((h) => h.color === color && h.startOffset === 0 && h.endOffset >= selectedVerse.text.length);
|
||||
if (existing) { removeHighlight(existing.id); return; }
|
||||
addTextHighlight({ book: readerBookAbbrev, chapter: readerChapter, verse: selectedVerse.number, startOffset: 0, endOffset: selectedVerse.text.length, color, text: selectedVerse.text });
|
||||
};
|
||||
const clearVerseHighlights = () => {
|
||||
const gone = verseHighlights;
|
||||
gone.forEach((h) => removeTextHighlight(h.id));
|
||||
if (gone.length) toast(`Removed ${gone.length} highlight${gone.length === 1 ? '' : 's'}`, { action: { label: 'Undo', onClick: () => gone.forEach((h) => restoreTextHighlight(h)) } });
|
||||
};
|
||||
const removeBookmark = (key, color) => {
|
||||
toggleReaderBookmark(key);
|
||||
toast('Bookmark removed', { action: { label: 'Undo', onClick: () => restoreReaderBookmark?.(key, color) } });
|
||||
@@ -302,8 +324,9 @@ export default function ReaderPage() {
|
||||
|
||||
// Render verse text with colored highlights and optional search match highlight
|
||||
const chapterSearch = sheet === 'search' && readerSearchScope === 'chapter' ? readerSearch.trim().toLowerCase() : '';
|
||||
function renderVerseText(verseNum, text, searchLow) {
|
||||
function renderVerseText(verseNum, text, searchLow, diffRanges) {
|
||||
const spans = [];
|
||||
for (const r of diffRanges ?? []) spans.push({ start: r.start, end: r.end, type: 'diff' });
|
||||
for (const h of (readerTextHighlights || [])) {
|
||||
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 });
|
||||
@@ -318,6 +341,11 @@ export default function ReaderPage() {
|
||||
}
|
||||
}
|
||||
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);
|
||||
const parts = [];
|
||||
let cursor = 0;
|
||||
@@ -332,6 +360,8 @@ export default function ReaderPage() {
|
||||
{text.slice(s.start, s.end)}
|
||||
</mark>,
|
||||
);
|
||||
} else if (s.type === 'diff') {
|
||||
parts.push(<span key={`diff${s.start}`} className="diff-hl">{text.slice(s.start, s.end)}</span>);
|
||||
} 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>);
|
||||
}
|
||||
@@ -376,6 +406,7 @@ export default function ReaderPage() {
|
||||
}).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 verseHighlights = selectedVerse ? (readerTextHighlights || []).filter((h) => h.book === readerBookAbbrev && h.chapter === readerChapter && h.verse === selectedVerse.number) : [];
|
||||
const selectedKey = selectedVerse ? `${readerBookAbbrev}-${readerChapter}-${selectedVerse.number}` : null;
|
||||
const title = `${readerBook?.name ?? ''} ${readerChapter} · ${readerTranslation}`;
|
||||
const audio = {
|
||||
@@ -386,7 +417,7 @@ export default function ReaderPage() {
|
||||
const paneOpen = !!selectedVerse && !readerDrawMode;
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-[var(--r-bg)] text-[var(--r-text)]" style={themeVars(readerTheme)}
|
||||
<div ref={rootRef} className="min-h-dvh bg-[var(--r-bg)] text-[var(--r-text)]" style={{ ...themeVars(readerTheme), ...(pane.width ? { '--reader-pane': `${pane.width}px` } : {}) }}
|
||||
onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd}>
|
||||
{chromeVisible && (
|
||||
<TopBar
|
||||
@@ -395,7 +426,10 @@ export default function ReaderPage() {
|
||||
onOpenNavigator={() => openSheet('nav')}
|
||||
onSearch={() => openSheet('search')}
|
||||
onBookmarks={() => openSheet('bookmarks')}
|
||||
bookmarkCount={bookmarkEntries.length + highlightEntries.length}
|
||||
bookmarkCount={bookmarkEntries.length}
|
||||
onHighlights={() => openSheet('highlights')}
|
||||
highlightCount={highlightEntries.length}
|
||||
onCompare={() => openSheet('compare')} compareActive={!!parallelId}
|
||||
onSettings={() => openSheet('settings')}
|
||||
onDoneDrawing={readerDrawMode ? () => setReaderDrawMode(false) : undefined}
|
||||
/>
|
||||
@@ -423,13 +457,13 @@ export default function ReaderPage() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className={paneOpen ? 'lg:pr-[24rem]' : ''}>
|
||||
<div className={paneOpen ? 'lg:pr-[var(--reader-pane,24rem)]' : ''}>
|
||||
{!readerDrawMode && (
|
||||
<ReaderText
|
||||
title={`${readerBook?.name ?? ''} ${readerChapter}`}
|
||||
translation={READER_TRANSLATIONS.find((t) => t.id === readerTranslation)?.name ?? readerTranslation}
|
||||
translationId={readerTranslation}
|
||||
font={readerFont} spacing={readerSpacing} showVerseNumbers={readerVerseNumbers}
|
||||
font={readerFont} spacing={readerSpacing} showVerseNumbers={readerVerseNumbers} showDiff={readerShowDiff}
|
||||
parallel={parallelId ? { translation: parallelId, name: parallelSource?.name ?? parallelId, notice: parallelSource?.notice ?? '', ...parallelChapter } : null}
|
||||
notice={readerNotice}
|
||||
verses={readerVerses}
|
||||
@@ -447,6 +481,7 @@ export default function ReaderPage() {
|
||||
searchLower={chapterSearch}
|
||||
crossRefs={readerCrossRefs}
|
||||
showCrossRefs={readerShowCrossRefs}
|
||||
onOpenCrossRef={(verse, ref) => { setReaderSelectedVerse(verse); setXrefRequest((r) => ({ verse, ref, n: (r?.n ?? 0) + 1 })); }}
|
||||
onSelectionEnd={handleSelectionEnd}
|
||||
footer={(
|
||||
<PlanFooter
|
||||
@@ -516,6 +551,17 @@ export default function ReaderPage() {
|
||||
onCycleColor={() => cycleBookmarkColor(selectedKey)}
|
||||
onJump={jump}
|
||||
onClose={() => setReaderSelectedVerse(null)}
|
||||
request={xrefRequest}
|
||||
panelRef={paneRef}
|
||||
resizeHandle={(
|
||||
// Drag this edge to resize (arrow keys too; double-click resets). Wide screens only.
|
||||
<div {...pane.handleProps(paneRef)}
|
||||
className="group absolute -left-1.5 top-0 z-20 hidden h-full w-3 cursor-col-resize touch-none select-none focus:outline-none lg:block">
|
||||
<span className="mx-auto block h-full w-px bg-transparent transition group-hover:w-0.5 group-hover:bg-[var(--r-accent)] group-focus-visible:w-0.5 group-focus-visible:bg-[var(--r-accent)]" />
|
||||
</div>
|
||||
)}
|
||||
verseHighlights={verseHighlights} colorLabels={readerColorLabels ?? {}}
|
||||
onHighlightVerse={highlightVerse} onClearVerseHighlights={clearVerseHighlights}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -549,7 +595,11 @@ export default function ReaderPage() {
|
||||
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
|
||||
open={sheet === 'search'} onClose={closeSheet}
|
||||
@@ -562,7 +612,8 @@ export default function ReaderPage() {
|
||||
onJump={jump}
|
||||
/>
|
||||
<MarksSheet
|
||||
open={sheet === 'bookmarks'} onClose={closeSheet}
|
||||
open={sheet === 'bookmarks' || sheet === 'highlights'} onClose={closeSheet}
|
||||
initialTab={sheet === 'bookmarks' ? 'bookmarks' : 'highlights'}
|
||||
bookmarks={bookmarkEntries} highlights={highlightEntries}
|
||||
colorLabels={readerColorLabels ?? {}} onSetLabel={setReaderColorLabel}
|
||||
onJump={jump}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { render, screen, fireEvent, within, waitFor } from '@testing-library/react';
|
||||
import { StrictMode } from 'react';
|
||||
import { vi, test, expect } from 'vitest';
|
||||
|
||||
const noop = () => {};
|
||||
@@ -47,6 +48,112 @@ test('renders the chapter, chrome and opens the tool sheets', () => {
|
||||
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('a cross-reference under a verse opens the side panel with that passage, without leaving the chapter', async () => {
|
||||
const saved = { ...ctx };
|
||||
Object.assign(ctx, {
|
||||
readerShowCrossRefs: true, readerSelectedVerse: 1,
|
||||
readerCrossRefs: { 1: [{ book: 'ROM', chapter: 5, verse: 8, endVerse: 9 }] },
|
||||
jumpToReaderVerse: vi.fn(),
|
||||
});
|
||||
vi.stubGlobal('fetch', vi.fn((url) => Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(String(url).includes('/api/bible/')
|
||||
? { verses: [{ number: 7, text: 'Seven.' }, { number: 8, text: 'But God shows His love for us.' }, { number: 9, text: 'Much more shall we be saved.' }, { number: 10, text: 'Ten.' }] }
|
||||
: { chapter: { content: [] } }),
|
||||
})));
|
||||
render(<StrictMode><ReaderPage /></StrictMode>); // dev builds run effects twice; the preview must survive that
|
||||
const chip = within(screen.getByRole('article')).getByRole('button', { name: 'Romans 5:8-9' });
|
||||
fireEvent.click(chip);
|
||||
const card = await screen.findByRole('region', { name: /cross-reference romans 5:8-9/i });
|
||||
await waitFor(() => expect(within(card).getByText(/But God shows His love/)).toBeTruthy());
|
||||
expect(within(card).getByText(/Much more shall we be saved/)).toBeTruthy();
|
||||
expect(within(card).queryByText('Seven.')).toBeNull(); // only the verses in the reference
|
||||
expect(ctx.setReaderSelectedVerse).not.toHaveBeenCalledWith(null); // the click didn't toggle the verse off
|
||||
fireEvent.click(within(card).getByRole('button', { name: /open chapter/i }));
|
||||
expect(ctx.jumpToReaderVerse).toHaveBeenCalledWith('ROM', 5, 8); // only now does it leave the chapter
|
||||
fireEvent.click(within(card).getByRole('button', { name: /close cross-reference/i }));
|
||||
expect(screen.queryByRole('region', { name: /cross-reference romans/i })).toBeNull();
|
||||
Object.assign(ctx, saved);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test('the verse panel has one-tap highlight colours for the whole verse', () => {
|
||||
const saved = { ...ctx };
|
||||
Object.assign(ctx, { readerSelectedVerse: 2, addTextHighlight: vi.fn(), removeTextHighlight: vi.fn(), restoreTextHighlight: vi.fn(), readerTextHighlights: [] });
|
||||
render(<ReaderPage />);
|
||||
const row = screen.getByRole('group', { name: /highlight this verse/i });
|
||||
fireEvent.click(within(row).getByRole('button', { name: /highlight verse green/i }));
|
||||
expect(ctx.addTextHighlight).toHaveBeenCalledWith(expect.objectContaining({
|
||||
book: 'JHN', chapter: 3, verse: 2, startOffset: 0, endOffset: 'This man came to Jesus by night.'.length, color: '#bbf7d0', text: 'This man came to Jesus by night.',
|
||||
}));
|
||||
Object.assign(ctx, saved);
|
||||
});
|
||||
|
||||
test('tapping a verse colour again takes it off, and Clear removes them all (with Undo)', () => {
|
||||
const saved = { ...ctx };
|
||||
const text = 'This man came to Jesus by night.';
|
||||
const hl = (id, color, start = 0, end = text.length) => ({ id, book: 'JHN', chapter: 3, verse: 2, startOffset: start, endOffset: end, color });
|
||||
Object.assign(ctx, { readerSelectedVerse: 2, addTextHighlight: vi.fn(), removeTextHighlight: vi.fn(), restoreTextHighlight: vi.fn(), readerTextHighlights: [hl('a', '#bbf7d0'), hl('b', '#fef08a', 5, 8)] });
|
||||
render(<ReaderPage />);
|
||||
const row = screen.getByRole('group', { name: /highlight this verse/i });
|
||||
expect(within(row).getByRole('button', { name: /highlight verse green/i })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(within(row).getByRole('button', { name: /highlight verse yellow/i })).toHaveAttribute('aria-pressed', 'false'); // a part-verse highlight isn't "the verse"
|
||||
fireEvent.click(within(row).getByRole('button', { name: /highlight verse green/i }));
|
||||
expect(ctx.removeTextHighlight).toHaveBeenCalledWith('a');
|
||||
expect(ctx.addTextHighlight).not.toHaveBeenCalled();
|
||||
fireEvent.click(within(row).getByRole('button', { name: /^clear$/i }));
|
||||
expect(ctx.removeTextHighlight).toHaveBeenCalledWith('b');
|
||||
Object.assign(ctx, saved);
|
||||
});
|
||||
|
||||
test('a Highlights button sits in the top bar and opens the highlights; Bookmarks opens the bookmarks', () => {
|
||||
const saved = { ...ctx };
|
||||
Object.assign(ctx, {
|
||||
readerTextHighlights: [{ id: 'h1', book: 'JHN', chapter: 3, verse: 2, startOffset: 0, endOffset: 4, color: '#bbf7d0', text: 'This' }],
|
||||
readerBookmarks: { 'JHN-3-1': '#fef08a' },
|
||||
});
|
||||
render(<ReaderPage />);
|
||||
const highlights = screen.getByLabelText('Highlights');
|
||||
expect(highlights.nextElementSibling).toBe(screen.getByLabelText('Bookmarks')); // beside the bookmarks button
|
||||
fireEvent.click(highlights);
|
||||
expect(screen.getByRole('tab', { name: /highlights \(1\)/i })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getByText(/“This”/)).toBeTruthy();
|
||||
fireEvent.click(screen.getByLabelText('Close'));
|
||||
fireEvent.click(screen.getByLabelText('Bookmarks'));
|
||||
expect(screen.getByRole('tab', { name: /bookmarks \(1\)/i })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getByText('John 3:1')).toBeTruthy();
|
||||
Object.assign(ctx, saved);
|
||||
});
|
||||
|
||||
test('the H key opens the highlights', () => {
|
||||
render(<ReaderPage />);
|
||||
fireEvent.keyDown(window, { key: 'h' });
|
||||
expect(screen.getByRole('dialog', { name: /bookmarks & highlights/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('the side panel has a drag handle to resize it, and no handle when no verse is open', () => {
|
||||
const saved = { ...ctx };
|
||||
render(<ReaderPage />);
|
||||
expect(screen.queryByRole('separator', { name: /resize side panel/i })).toBeNull();
|
||||
Object.assign(ctx, { readerSelectedVerse: 1 });
|
||||
render(<ReaderPage />);
|
||||
const handle = screen.getByRole('separator', { name: /resize side panel/i });
|
||||
expect(handle).toHaveAttribute('aria-orientation', 'vertical');
|
||||
expect(handle).toHaveAttribute('tabindex', '0'); // reachable by keyboard (arrow keys resize)
|
||||
Object.assign(ctx, saved);
|
||||
});
|
||||
|
||||
test('tapping a verse selects it', () => {
|
||||
render(<ReaderPage />);
|
||||
fireEvent.click(screen.getByText(/This man came to Jesus/));
|
||||
|
||||
@@ -103,6 +103,21 @@ export async function disableSharing(id) {
|
||||
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. */
|
||||
export async function getSharedProject(token) {
|
||||
return request('GET', `/share/${token}`);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { labelFor as outlineLabel } from './outlineLabels.js';
|
||||
import { labelFor as outlineLabel, kindLabel as outlineKindLabel } from './outlineLabels.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Study constants
|
||||
@@ -193,6 +193,10 @@ export function buildExportHtml(project) {
|
||||
.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; }
|
||||
@media print {
|
||||
@@ -206,7 +210,7 @@ export function buildExportHtml(project) {
|
||||
|
||||
// The outline, if one was written: nested lists, labelled I. / A. / 1. like on the page
|
||||
const outlineHtml = (project.outline ?? []).length > 0
|
||||
? `<section class="outline"><h2 class="chapter-heading">Outline</h2>${outlineListHtml(project.outline, 0)}</section>`
|
||||
? `<section class="outline"><h2 class="chapter-heading">Outline</h2>${outlineTimeHtml(project.outline)}${outlineListHtml(project.outline, 0)}</section>`
|
||||
: '';
|
||||
|
||||
const chunksHtml = chapters.map((ch, chapterIndex) => {
|
||||
@@ -268,6 +272,7 @@ export function buildExportHtml(project) {
|
||||
<section class="chunk">
|
||||
<div class="scripture-ref">${escHtml(scripture)}</div>
|
||||
${versesText}
|
||||
${(chunk.outline ?? []).length > 0 ? `<div class="outline"><strong>OUTLINE:</strong>${outlineListHtml(chunk.outline, 0)}</div>` : ''}
|
||||
<div class="oia">
|
||||
<div class="oia-section"><strong>OBSERVATION:</strong><p>${observation}</p></div>
|
||||
<div class="oia-section"><strong>INTERPRETATION:</strong><p>${interpretation}</p></div>
|
||||
@@ -304,8 +309,81 @@ export function buildExportHtml(project) {
|
||||
}
|
||||
|
||||
// <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) {
|
||||
return `<ul>${nodes.map((n, i) => `<li><span class="lbl">${escHtml(outlineLabel(depth, i))}</span>${escHtml((n.text ?? '').trim() || '(untitled)')}${n.ref ? ` <span class="ref">(${escHtml(n.ref)})</span>` : ''}${n.children?.length ? outlineListHtml(n.children, depth + 1) : ''}</li>`).join('')}</ul>`;
|
||||
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) {
|
||||
|
||||
+62
-12
@@ -14,7 +14,8 @@ import {
|
||||
WidthType,
|
||||
} from 'docx';
|
||||
import { formatChunkReference, getChunkVerseEntries, htmlToPlainText } from './bibleUtils.jsx';
|
||||
import { flatten } from './outline.js';
|
||||
import { detailLines, flatten, timeSummary } from './outline.js';
|
||||
import { kindLabel } from './outlineLabels.js';
|
||||
|
||||
export function createParagraphsFromText(text) {
|
||||
return text
|
||||
@@ -73,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) {
|
||||
const children = [
|
||||
new Paragraph({ text: project.title, heading: HeadingLevel.TITLE }),
|
||||
@@ -93,17 +147,8 @@ export async function buildStudyDocxBlob(project, chapterSummary) {
|
||||
// The outline comes first, indented by level like the page
|
||||
if ((project.outline ?? []).length > 0) {
|
||||
children.push(new Paragraph({ text: 'Outline', heading: HeadingLevel.HEADING_1, spacing: { before: 240, after: 160 } }));
|
||||
flatten(project.outline).forEach(({ node, depth, label }) => {
|
||||
children.push(new Paragraph({
|
||||
indent: { left: 420 * depth, hanging: 420 },
|
||||
spacing: { after: 80 },
|
||||
children: [
|
||||
new TextRun({ text: `${label}\t`, bold: true, color: DOCX_ACCENT }),
|
||||
new TextRun({ text: (node.text ?? '').trim() || '(untitled)' }),
|
||||
...(node.ref ? [new TextRun({ text: ` (${node.ref})`, italics: true, color: '7A7060' })] : []),
|
||||
],
|
||||
}));
|
||||
});
|
||||
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) => {
|
||||
@@ -154,6 +199,11 @@ export async function buildStudyDocxBlob(project, chapterSummary) {
|
||||
}));
|
||||
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()) {
|
||||
children.push(sectionLabel('Background / General Notes'));
|
||||
children.push(...createParagraphsFromText(chunk.generalNotes));
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
getChunkVerseEntries,
|
||||
htmlToPlainText,
|
||||
} from './bibleUtils.jsx';
|
||||
import { outlineToMarkdown } from './outline.js';
|
||||
import { outlineToMarkdown, timeSummary } from './outline.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export / prompt builders (exported for testing)
|
||||
@@ -43,13 +43,15 @@ export function buildMarkdownExport(project) {
|
||||
return `- ${summary}${definition}`;
|
||||
}).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');
|
||||
|
||||
return `## ${ch.book} ${ch.chapter}\n\n${chunkSections}`;
|
||||
}).join('\n');
|
||||
|
||||
const outline = (project.outline ?? []).length > 0 ? `## Outline\n\n${outlineToMarkdown(project.outline)}\n\n` : '';
|
||||
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}`;
|
||||
}
|
||||
|
||||
|
||||
+79
-7
@@ -1,14 +1,18 @@
|
||||
// A study's true outline: main points, sub-points under them, and so on (I. / A. / 1. / a.). It is a tree
|
||||
// of nodes { id, text, ref, chunkId, children } kept on the project as `project.outline`. Every function
|
||||
// returns a new tree and leaves the one it was given alone.
|
||||
import { formatChunkReference, makeId } from './bibleUtils.jsx';
|
||||
import { labelFor, toRoman } from './outlineLabels.js';
|
||||
import { formatChunkReference, getChunkVerseEntries, makeId } from './bibleUtils.jsx';
|
||||
import { KINDS, kindLabel, labelFor, toRoman } from './outlineLabels.js';
|
||||
|
||||
export { labelFor, toRoman };
|
||||
export { KINDS, kindLabel, labelFor, toRoman };
|
||||
|
||||
export const MAX_DEPTH = 5; // levels below the main points
|
||||
|
||||
export const newNode = (text = '', extra = {}) => ({ id: makeId(), text, ref: '', chunkId: null, children: [], ...extra });
|
||||
// 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 ---------------------------------------------------------------
|
||||
|
||||
@@ -136,14 +140,44 @@ export function flatten(tree, depth = 0, parentId = null) {
|
||||
|
||||
export const countNodes = (tree) => flatten(tree).length;
|
||||
|
||||
const pointText = (n) => `${n.text.trim() || '(untitled)'}${n.ref.trim() ? ` (${n.ref.trim()})` : ''}`;
|
||||
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).map(({ node, depth, label }) => `${' '.repeat(depth)}${label} ${pointText(node)}`).join('\n');
|
||||
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).map(({ node, depth, label }) => `${' '.repeat(depth)}- **${label}** ${pointText(node)}`).join('\n');
|
||||
return flatten(tree).flatMap(({ node, depth, label }) => [
|
||||
`${' '.repeat(depth)}- **${label}** ${pointText(node)}`,
|
||||
...detailLines(node).map((l) => `${' '.repeat(depth)} *${l}*`),
|
||||
]).join('\n');
|
||||
}
|
||||
|
||||
// ---- starting one ------------------------------------------------------------
|
||||
@@ -162,3 +196,41 @@ export function seedFromChunks(project, tree = []) {
|
||||
});
|
||||
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];
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
addAfter, addChild, addRoot, countNodes, flatten, getNode, indent, labelFor, moveNode, newNode, outdent,
|
||||
insertAt, locate, outlineToMarkdown, outlineToText, removeNode, seedFromChunks, toRoman, updateNode, MAX_DEPTH,
|
||||
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 });
|
||||
@@ -120,3 +120,60 @@ describe('putting a deleted point back', () => {
|
||||
expect(ids(insertAt([n('b')], 'missing', 0, n('x')))).toEqual(['x', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('richer points', () => {
|
||||
const rich = [{
|
||||
...n('a', 'Creation'), ref: 'Gen 1', kind: 'illustration', minutes: 5, note: 'Tell the story.\n\nKeep it short.',
|
||||
scripture: { ref: 'Genesis 1:1', text: 'In the beginning', translation: 'BSB' },
|
||||
attachments: [{ id: 'x', type: 'xref', label: 'John 1:1' }, { id: 'w', type: 'word', label: 'bara (H1254)', detail: 'to create' }],
|
||||
children: [{ ...n('b', 'Light'), minutes: 3 }],
|
||||
}];
|
||||
test('text and markdown show the kind, minutes, note, verse and attachments', () => {
|
||||
expect(outlineToText(rich)).toBe([
|
||||
'I. [Illustration] Creation (Gen 1) · 5 min',
|
||||
' Tell the story.',
|
||||
' Keep it short.',
|
||||
' “In the beginning” — Genesis 1:1',
|
||||
' See: John 1:1',
|
||||
' Word: bara (H1254) — to create',
|
||||
' A. Light · 3 min',
|
||||
].join('\n'));
|
||||
expect(outlineToMarkdown(rich)).toContain('- **I.** [Illustration] Creation (Gen 1) · 5 min\n *Tell the story.*');
|
||||
});
|
||||
test('totals the minutes, and says nothing when there are none', () => {
|
||||
expect(totalMinutes(rich)).toBe(8);
|
||||
expect(timeSummary(rich)).toBe('About 8 minutes');
|
||||
expect(timeSummary([n('z')])).toBe('');
|
||||
});
|
||||
test('new points start plain, and older ones without the new fields still work', () => {
|
||||
expect(newNode('x')).toMatchObject({ note: '', kind: '', minutes: null, scripture: null, attachments: [] });
|
||||
expect(detailLines(n('old'))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('an outline for each chunk', () => {
|
||||
const project = {
|
||||
translation: 'BSB', outline: [n('whole')],
|
||||
chapters: [{ book: 'Titus', bookAbbrev: 'TIT', chapter: '1', verses: [{ number: 1, text: 'One.' }, { number: 2, text: 'Two.' }, { number: 3, text: 'Three.' }], chunks: [
|
||||
{ id: 'c1', startVerse: 1, endVerse: 2, observation: '', outline: [n('mine')] },
|
||||
{ id: 'c2', startVerse: 3, endVerse: 3, observation: '' },
|
||||
] }],
|
||||
};
|
||||
test('reads and replaces the outline of the whole study or of one chunk', () => {
|
||||
expect(ids(outlineFor(project, STUDY))).toEqual(['whole']);
|
||||
expect(ids(outlineFor(project, 'c1'))).toEqual(['mine']);
|
||||
expect(outlineFor(project, 'c2')).toEqual([]);
|
||||
const next = withOutline(project, 'c2', [n('new')]);
|
||||
expect(ids(outlineFor(next, 'c2'))).toEqual(['new']);
|
||||
expect(ids(outlineFor(next, 'c1'))).toEqual(['mine']);
|
||||
expect(ids(outlineFor(next, STUDY))).toEqual(['whole']);
|
||||
expect(outlineFor(project, 'c2')).toEqual([]); // the original is untouched
|
||||
expect(ids(outlineFor(withOutline(project, STUDY, []), STUDY))).toEqual([]);
|
||||
});
|
||||
test('seeding from verses adds a point per verse with its words, and never twice', () => {
|
||||
const t = seedFromVerses(project, 'c1', []);
|
||||
expect(t.map((x) => [x.ref, x.scripture.text])).toEqual([['Titus 1:1', 'One.'], ['Titus 1:2', 'Two.']]);
|
||||
expect(seedFromVerses(project, 'c1', t)).toHaveLength(2);
|
||||
expect(seedFromVerses(project, 'c2', [])).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
// Taking one outline out of the app: print or save as PDF, share it, or download it.
|
||||
import { buildOutlineHtml } from './bibleUtils.jsx';
|
||||
import { outlineToText } from './outline.js';
|
||||
|
||||
const slug = (s) => String(s).replace(/[^\w]+/g, '-').replace(/^-|-$/g, '') || 'outline';
|
||||
|
||||
function downloadBlob(blob, filename) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// What gets shared as text: a heading, then the outline.
|
||||
export const outlineShareText = ({ title, subtitle = '', tree }) => `${title}${subtitle ? `\n${subtitle}` : ''}\n\n${outlineToText(tree)}`;
|
||||
|
||||
// Opens a print-ready page of just this outline. Returns false when the browser blocked the window.
|
||||
export function printOutline({ title, subtitle, tree }) {
|
||||
const win = window.open('', '_blank');
|
||||
if (!win) return false;
|
||||
win.document.write(buildOutlineHtml({ title, subtitle, tree }));
|
||||
win.document.close();
|
||||
win.onload = () => { win.focus(); win.print(); };
|
||||
return true;
|
||||
}
|
||||
|
||||
// Uses the phone/tablet/computer share sheet when there is one (Messages, Mail, AirDrop…), otherwise
|
||||
// copies the text. -> 'shared' | 'copied' | 'cancelled' | 'failed'
|
||||
export async function shareOutline(outline) {
|
||||
const text = outlineShareText(outline);
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({ title: `${outline.title} — Outline`, text });
|
||||
return 'shared';
|
||||
} catch (err) {
|
||||
if (err?.name === 'AbortError') return 'cancelled';
|
||||
// fall through to copying
|
||||
}
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return 'copied';
|
||||
} catch {
|
||||
return 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
export function downloadOutlineMarkdown({ title, subtitle, tree }) {
|
||||
const text = `# ${title}\n\n${subtitle ? `*${subtitle}*\n\n` : ''}${tree.length ? outlineToText(tree) : ''}\n`;
|
||||
downloadBlob(new Blob([text], { type: 'text/markdown;charset=utf-8' }), `${slug(title)}-outline.md`);
|
||||
}
|
||||
|
||||
export async function downloadOutlineDocx(outline) {
|
||||
const { buildOutlineDocxBlob } = await import('./docxExport.js'); // the Word library only loads when used
|
||||
downloadBlob(await buildOutlineDocxBlob(outline), `${slug(outline.title)}-outline.docx`);
|
||||
}
|
||||
@@ -0,0 +1,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 <b>');
|
||||
expect(html).toContain('<i>hi</i>');
|
||||
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);
|
||||
});
|
||||
@@ -26,3 +26,9 @@ export function labelFor(depth, index) {
|
||||
default: return `(${letter(n, false)})`;
|
||||
}
|
||||
}
|
||||
|
||||
export const KINDS = [
|
||||
['', 'Point'], ['illustration', 'Illustration'], ['application', 'Application'],
|
||||
['quote', 'Quote'], ['question', 'Question'], ['transition', 'Transition'],
|
||||
];
|
||||
export const kindLabel = (kind) => KINDS.find(([id]) => id === kind)?.[1] ?? '';
|
||||
@@ -0,0 +1,49 @@
|
||||
// "Titus 1:5–9" -> the words of those verses, for putting scripture inside an outline point.
|
||||
import { matchBooks } from './passageRef.js';
|
||||
import { CHAPTER_COUNTS, bookOptions } from './bibleUtils.jsx';
|
||||
import { fetchChapter } from './bibleText.js';
|
||||
|
||||
export const MAX_VERSES = 40; // a whole chapter's worth is plenty to paste into a point
|
||||
|
||||
// -> { book, bookName, chapter, start, end } (start/end null = the whole chapter) or null. Accepts
|
||||
// "Titus 1:5-9", "titus 1:5", "John 3" and a dash or en dash.
|
||||
export function parsePassageRange(text) {
|
||||
const m = String(text ?? '').trim().replace(/\./g, ' ').match(/^((?:[1-3]\s*)?[a-z][a-z ]*?)\s*(\d{1,3})(?:\s*[:v ]\s*(\d{1,3})(?:\s*[-–—]\s*(\d{1,3}))?)?$/i);
|
||||
if (!m) return null;
|
||||
const [, name, chapterText, startText, endText] = m;
|
||||
const book = matchBooks(name)[0];
|
||||
const chapter = Number(chapterText);
|
||||
if (!book || chapter < 1 || chapter > (CHAPTER_COUNTS[book.abbrev] ?? 1)) return null;
|
||||
const start = startText ? Number(startText) : null;
|
||||
const end = endText ? Number(endText) : start;
|
||||
if (start != null && end < start) return null;
|
||||
return { book: book.abbrev, bookName: bookOptions.find((b) => b.abbrev === book.abbrev)?.name ?? book.name, chapter, start, end };
|
||||
}
|
||||
|
||||
export const formatRange = (r) => `${r.bookName} ${r.chapter}${r.start == null ? '' : r.end === r.start ? `:${r.start}` : `:${r.start}–${r.end}`}`;
|
||||
|
||||
// -> { ref, text, translation }. Throws Error(message) when the passage can't be read or found.
|
||||
export async function fetchPassageText(text, translation = 'BSB') {
|
||||
const range = parsePassageRange(text);
|
||||
if (!range) throw new Error('Type a passage like “Titus 1:5–9”.');
|
||||
const { verses } = await fetchChapter(translation, range.book, range.chapter);
|
||||
const picked = range.start == null ? verses : verses.filter((v) => v.number >= range.start && v.number <= range.end);
|
||||
if (picked.length === 0) throw new Error('Those verses weren’t found in that chapter.');
|
||||
if (picked.length > MAX_VERSES) throw new Error(`That's ${picked.length} verses — pick ${MAX_VERSES} or fewer.`);
|
||||
return { ref: formatRange(range), text: picked.map((v) => v.text).join(' '), translation };
|
||||
}
|
||||
|
||||
// What a chunk offers to attach to a point: its cross-references and the words studied in it.
|
||||
// -> [{ key, type: 'xref' | 'word', label, detail? }]
|
||||
export function attachableFromChunk(chunk) {
|
||||
if (!chunk) return [];
|
||||
const refs = (chunk.crossReferences ?? []).map((r) => ({ key: `x:${r}`, type: 'xref', label: r }));
|
||||
const words = (chunk.greekWords ?? [])
|
||||
.filter((w) => w.lexeme || w.strongNumber || w.query)
|
||||
.map((w) => ({
|
||||
key: `w:${w.strongNumber || w.lexeme || w.query}`, type: 'word',
|
||||
label: [w.lexeme, w.strongNumber && `(${w.strongNumber})`].filter(Boolean).join(' ') || w.query,
|
||||
detail: w.shortDefinition && !/^(No definition|Lookup failed)/.test(w.shortDefinition) ? w.shortDefinition : undefined,
|
||||
}));
|
||||
return [...refs, ...words];
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
import { attachableFromChunk, fetchPassageText, parsePassageRange } from './passageText.js';
|
||||
|
||||
const chapter = { chapter: { content: [1, 2, 3, 4, 5].map((n) => ({ type: 'verse', number: n, content: [`Verse ${n} text.`] })) } };
|
||||
beforeEach(() => vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(chapter) }))));
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
test('reads a verse, a range, or a whole chapter', () => {
|
||||
expect(parsePassageRange('Titus 1:5')).toMatchObject({ book: 'TIT', chapter: 1, start: 5, end: 5 });
|
||||
expect(parsePassageRange('titus 1:5-9')).toMatchObject({ start: 5, end: 9 });
|
||||
expect(parsePassageRange('1 Cor 13:4–7')).toMatchObject({ book: '1CO', chapter: 13, start: 4, end: 7 });
|
||||
expect(parsePassageRange('John 3')).toMatchObject({ chapter: 3, start: null });
|
||||
});
|
||||
|
||||
test('rejects things that are not passages, or backwards ranges', () => {
|
||||
expect(parsePassageRange('hello')).toBeNull();
|
||||
expect(parsePassageRange('Titus 9:1')).toBeNull();
|
||||
expect(parsePassageRange('Titus 1:9-5')).toBeNull();
|
||||
});
|
||||
|
||||
test('fetches just the verses asked for, and names the passage', async () => {
|
||||
const got = await fetchPassageText('Titus 1:2-3');
|
||||
expect(got).toEqual({ ref: 'Titus 1:2–3', text: 'Verse 2 text. Verse 3 text.', translation: 'BSB' });
|
||||
});
|
||||
|
||||
test('says so when the verses are not there, or the passage is not understood', async () => {
|
||||
await expect(fetchPassageText('Titus 1:50')).rejects.toThrow(/weren’t found/);
|
||||
await expect(fetchPassageText('nonsense')).rejects.toThrow(/Type a passage/);
|
||||
});
|
||||
|
||||
test('offers a chunk\'s cross-references and studied words to attach', () => {
|
||||
const chunk = {
|
||||
crossReferences: ['John 1:1'],
|
||||
greekWords: [
|
||||
{ strongNumber: 'G1401', lexeme: 'δοῦλος', shortDefinition: 'a slave' },
|
||||
{ query: 'xyz', shortDefinition: 'No definition found.' },
|
||||
],
|
||||
};
|
||||
expect(attachableFromChunk(chunk)).toEqual([
|
||||
{ key: 'x:John 1:1', type: 'xref', label: 'John 1:1' },
|
||||
{ key: 'w:G1401', type: 'word', label: 'δοῦλος (G1401)', detail: 'a slave' },
|
||||
{ key: 'w:xyz', type: 'word', label: 'xyz', detail: undefined },
|
||||
]);
|
||||
expect(attachableFromChunk(null)).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,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`;
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user