Compare commits

...
Author SHA1 Message Date
nmemmertandClaude Sonnet 5.5 a9ca647d5a Reader: Highlights button in the top bar
A highlighter button beside Bookmarks opens your highlights (by colour);
Bookmarks now opens the bookmarks. Each shows in the accent colour when you
have some. "H" opens the highlights.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:34:02 -04:00
nmemmertandClaude Sonnet 5.5 22f9700798 Merge tooltips: hover tooltips, clickable cross-references, easier highlighting
Build and Push Docker Image / build-and-push (push) Failing after 20s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:31:44 -04:00
nmemmertandClaude Sonnet 5.5 7f0256be06 Hover tooltips, clickable cross-references, easier highlighting
- Tooltips on hover and keyboard focus across the app: icon-only buttons show
  their label, existing titles show in the app's style without the browser's
  delay, and the reader's main buttons show shortcut hints ("Search (/)").
  Touch screens are unaffected.
- Reader: cross-references (under verses and in the Cross-refs tab) open the
  passage's text in the side panel without leaving the chapter; "Open chapter"
  goes there when wanted
- Reader: the verse panel now has a Highlight row of colours for one-tap
  whole-verse highlighting (tap again to remove, Clear for all, with Undo);
  selecting words still highlights just those
- Fix: a cross-reference preview was cleared when React ran the panel's
  effect twice (development builds)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:31:27 -04:00
nmemmertandClaude Sonnet 5.5 bb80623c45 Merge login-redesign: login page showing what the app can do
Build and Push Docker Image / build-and-push (push) Failing after 19s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:19:06 -04:00
nmemmertandClaude Sonnet 5.5 46a6e3bf7a Redesign the login page around what the app can do
- Hero ("Read it. Study it. Teach it.") with small previews of the outline
  and the translation comparison
- Four groups of features (Read, Study, Teach, Everywhere), all things the
  app really does
- Sign-in / create-account card stays in view beside the tour on wide
  screens and comes first on a phone; theme switcher before signing in
- Sign-in, account creation, 2FA and error handling unchanged

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

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:02:40 -04:00
nmemmertandClaude Sonnet 5.5 2ca79730f1 Merge reader-compare: translation comparison with a button next to Aa
Build and Push Docker Image / build-and-push (push) Successful in 4m9s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:40:28 -04:00
27 changed files with 1215 additions and 109 deletions

No files matched your search

+26
View File
@@ -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);
+46
View File
@@ -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 {
+59
View File
@@ -0,0 +1,59 @@
// What a read-only outline link shows: the outlines of a study (the whole-study one and each chunk's)
// and nothing else — no observations, interpretations, applications, scripts or word notes. The
// outline data comes from the browser, so it is rebuilt field by field instead of passed through.
const MAX_NODES = 2000;
const MAX_DEPTH = 8;
const KINDS = new Set(['', 'illustration', 'application', 'quote', 'question', 'transition']);
const str = (v, max) => (typeof v === 'string' ? v.slice(0, max) : '');
function cleanTree(nodes, depth, counter) {
if (!Array.isArray(nodes) || depth > MAX_DEPTH) return [];
const out = [];
for (const n of nodes) {
if (!n || typeof n !== 'object' || counter.n >= MAX_NODES) continue;
counter.n += 1;
const minutes = Number.isFinite(n.minutes) && n.minutes >= 0 && n.minutes <= 600 ? n.minutes : null;
out.push({
id: str(n.id, 64) || `n${counter.n}`,
text: str(n.text, 2000),
ref: str(n.ref, 200),
kind: KINDS.has(n.kind) ? n.kind : '',
minutes,
note: str(n.note, 8000),
scripture: n.scripture && typeof n.scripture.text === 'string'
? { ref: str(n.scripture.ref, 200), text: str(n.scripture.text, 6000), translation: str(n.scripture.translation, 12) }
: null,
attachments: (Array.isArray(n.attachments) ? n.attachments : []).slice(0, 50)
.filter((a) => a && typeof a === 'object')
.map((a) => ({ id: str(a.id, 100), type: a.type === 'word' ? 'word' : 'xref', label: str(a.label, 200), ...(a.detail ? { detail: str(a.detail, 500) } : {}) })),
children: cleanTree(n.children, depth + 1, counter),
});
}
return out;
}
const chunkLabel = (chapters, chapterIndex, chunk) => {
const ch = chapters[chapterIndex];
const next = chapters[chapterIndex + 1];
if (Number.isInteger(chunk.spilloverEndVerse) && next && next.bookAbbrev === ch.bookAbbrev) {
return `${ch.book} ${ch.chapter}:${chunk.startVerse}–${next.chapter}:${chunk.spilloverEndVerse}`;
}
return chunk.startVerse === chunk.endVerse ? `${ch.book} ${ch.chapter}:${chunk.startVerse}` : `${ch.book} ${ch.chapter}:${chunk.startVerse}–${chunk.endVerse}`;
};
// -> { title, translation, outlines: [{ scope, label, tree }] } with only the outlines that have points
export function buildOutlinePayload(project) {
const counter = { n: 0 };
const outlines = [];
const whole = cleanTree(project?.outline, 0, counter);
if (whole.length > 0) outlines.push({ scope: 'study', label: 'Whole study', tree: whole });
const chapters = Array.isArray(project?.chapters) ? project.chapters : [];
chapters.forEach((ch, chapterIndex) => {
for (const chunk of Array.isArray(ch?.chunks) ? ch.chunks : []) {
const tree = cleanTree(chunk?.outline, 0, counter);
if (tree.length > 0) outlines.push({ scope: str(chunk.id, 64), label: str(chunkLabel(chapters, chapterIndex, chunk), 200), tree });
}
});
return { title: str(project?.title, 500), translation: str(project?.translation, 12) || 'BSB', outlines };
}
+42
View File
@@ -0,0 +1,42 @@
import { expect, test } from 'vitest';
import { buildOutlinePayload } from './outlineShare.js';
const node = (id, extra = {}) => ({ id, text: id, ref: '', kind: '', minutes: null, note: '', scripture: null, attachments: [], children: [], ...extra });
const project = {
title: 'Titus', translation: 'BSB',
outline: [node('a', { minutes: 5, kind: 'illustration', children: [node('a1')] })],
chapters: [{
book: 'Titus', bookAbbrev: 'TIT', chapter: '1', verses: [{ number: 1, text: 'secret verse text' }],
chunks: [
{ id: 'c1', startVerse: 1, endVerse: 4, observation: 'PRIVATE OBSERVATION', finalScript: 'PRIVATE SCRIPT', greekWords: [{ lexeme: 'x' }], outline: [node('b')] },
{ id: 'c2', startVerse: 5, endVerse: 5, observation: 'more private', outline: [] },
],
}],
};
test('shares the outlines only, with each chunk labelled by its passage', () => {
const p = buildOutlinePayload(project);
expect(p.title).toBe('Titus');
expect(p.outlines.map((o) => [o.scope, o.label])).toEqual([['study', 'Whole study'], ['c1', 'Titus 1:1–4']]);
expect(p.outlines[0].tree[0]).toMatchObject({ text: 'a', minutes: 5, kind: 'illustration' });
expect(p.outlines[0].tree[0].children[0].text).toBe('a1');
});
test('nothing from the rest of the study leaks into what is shared', () => {
const json = JSON.stringify(buildOutlinePayload(project));
for (const secret of ['PRIVATE OBSERVATION', 'PRIVATE SCRIPT', 'secret verse text', 'more private', 'greekWords']) expect(json).not.toContain(secret);
});
test('unknown fields on a point are dropped, and bad values are cleaned', () => {
const p = buildOutlinePayload({ title: 'T', outline: [{ id: 'x', text: 'hi', evil: '<script>', kind: 'bogus', minutes: -4, scripture: { text: 5 }, attachments: [{ type: 'word', label: 'L' }, null], children: 'nope' }], chapters: [] });
expect(p.outlines[0].tree[0]).toEqual({
id: 'x', text: 'hi', ref: '', kind: '', minutes: null, note: '', scripture: null,
attachments: [{ id: '', type: 'word', label: 'L' }], children: [],
});
});
test('a study with no outlines shares none, and a huge outline is capped', () => {
expect(buildOutlinePayload({ title: 'T', chapters: [] }).outlines).toEqual([]);
const big = { title: 'T', outline: Array.from({ length: 3000 }, (_, i) => node(`n${i}`)), chapters: [] };
expect(buildOutlinePayload(big).outlines[0].tree).toHaveLength(2000);
});
+125 -2
View File
@@ -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 })
@@ -282,12 +294,21 @@ describe('Outline: per chunk, details, print and share', () => {
expect(await screen.findByRole('alert')).toHaveTextContent(/type a passage/i);
});
test('Share copies the outline when there is no share sheet; Print opens a page of it', async () => {
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 }));
@@ -295,6 +316,58 @@ describe('Outline: per chunk, details, print and share', () => {
});
});
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(', ');
@@ -318,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
View File
@@ -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) {
+55
View File
@@ -0,0 +1,55 @@
// An outline laid out for reading (not editing): numbered points, small type tags, notes, verse text. Used
// by the read-only share page. Styled to match the printed / PDF page.
import { labelFor } from '../utils/outlineLabels.js';
import { kindLabel } from '../utils/outlineLabels.js';
function Points({ nodes, depth }) {
return (
<ul className={depth === 0 ? '' : 'ml-6 sm:ml-10'}>
{nodes.map((n, i) => {
const main = depth === 0;
const mins = Number.isFinite(n.minutes) && n.minutes > 0;
return (
<li key={n.id} className={main ? 'mt-7 first:mt-5' : 'mt-2.5'}>
<div className="flex gap-3">
<span className={`shrink-0 font-semibold tabular-nums text-[var(--r-accent)] ${main ? 'w-9 text-lg' : 'w-8'}`}>{labelFor(depth, i)}</span>
<div className="min-w-0 flex-1">
<p className={`leading-snug ${main ? 'text-xl font-semibold' : 'text-[1.05rem]'}`}>
{n.kind && <span className="mr-2 inline-block -translate-y-px rounded-full border border-[#e4b9a3] px-2 py-0.5 align-middle font-sans text-[10px] font-semibold uppercase tracking-widest text-[var(--r-accent)]">{kindLabel(n.kind)}</span>}
{n.text.trim() || <span className="text-[var(--r-sub)]">(untitled)</span>}
{n.ref && <span className="ml-2 whitespace-nowrap font-sans text-[0.8rem] font-normal text-[var(--r-sub)]">{n.ref}</span>}
{mins && <span className="ml-2 whitespace-nowrap font-sans text-[0.8rem] font-normal text-[var(--r-sub)]">{n.minutes} min</span>}
</p>
{n.note.trim() && <p className="mt-1 whitespace-pre-line text-[0.95rem] italic leading-relaxed text-[var(--r-sub)]">{n.note.trim()}</p>}
{n.scripture?.text && (
<blockquote className="mt-2 border-l-[3px] border-[#e4b9a3] pl-4 italic leading-relaxed">
“{n.scripture.text}” <span className="whitespace-nowrap font-sans text-[0.8rem] not-italic text-[var(--r-sub)]">— {n.scripture.ref}</span>
</blockquote>
)}
{n.attachments.length > 0 && (
<p className="mt-1.5 font-sans text-[0.8rem] leading-relaxed text-[var(--r-sub)]">
{n.attachments.map((a, k) => <span key={a.id || k}>{k > 0 && ' · '}{a.type === 'word' ? 'Word' : 'See'}: {a.label}{a.detail ? ` — ${a.detail}` : ''}</span>)}
</p>
)}
{n.children.length > 0 && <Points nodes={n.children} depth={depth + 1} />}
</div>
</div>
</li>
);
})}
</ul>
);
}
export default function OutlineDocument({ title, meta, tree }) {
return (
<article className="mx-auto max-w-[44rem] px-5 pb-20 pt-10 font-serif text-[var(--r-text)] sm:px-8 sm:pt-14">
<p className="font-sans text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--r-accent)]">Outline</p>
<h1 className="mt-3 text-3xl font-semibold leading-tight tracking-tight sm:text-[2.1rem]">{title}</h1>
{meta && <p className="mt-2 font-sans text-sm text-[var(--r-sub)]">{meta}</p>}
<hr className="mb-1 mt-6 border-[var(--r-line)]" />
{tree.length > 0 ? <Points nodes={tree} depth={0} /> : <p className="mt-8 text-[var(--r-sub)]">Nothing in this outline yet.</p>}
<p className="mt-14 border-t border-[var(--r-line)] pt-4 text-center font-sans text-[11px] tracking-wide text-[var(--r-sub)]">Bible Study Project</p>
</article>
);
}
+66
View File
@@ -0,0 +1,66 @@
// Share an outline: a read-only link anyone can open (no account), or the plain text.
import { useState } from 'react';
import Sheet from './reader/Sheet.jsx';
import { useToast } from './Toasts.jsx';
import { useOutlineShareLink } from '../hooks/useOutlineShareLink.js';
import { shareOutline } from '../utils/outlineExport.js';
const btn = 'rounded-xl border border-[var(--r-line)] px-3.5 py-2 text-sm hover:bg-black/5 disabled:opacity-50';
export default function ShareOutlineSheet({ open, onClose, projectId, outline }) {
const { toast } = useToast();
const link = useOutlineShareLink(projectId, open);
const [copied, setCopied] = useState(false);
const copyLink = async () => {
try { await navigator.clipboard.writeText(link.url); setCopied(true); window.setTimeout(() => setCopied(false), 2000); } catch { toast('Couldn’t copy. Select the link and copy it.', { tone: 'error' }); }
};
// The phone/computer share sheet, with the link (falls back to copying it)
const shareLink = async () => {
if (navigator.share) {
try { await navigator.share({ title: `${outline.title} — Outline`, url: link.url }); return; } catch (err) { if (err?.name === 'AbortError') return; }
}
copyLink();
};
const shareText = async () => {
const result = await shareOutline(outline);
if (result === 'copied') toast('Outline copied. Paste it into a message or email.');
else if (result === 'failed') toast('Couldn’t share or copy the outline.', { tone: 'error' });
};
return (
<Sheet open={open} onClose={onClose} title="Share this outline">
<h3 className="text-sm font-medium">Read-only link</h3>
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">
Anyone with the link can read this study’s outlines (the whole-study one and each chunk’s), and print them. They can’t edit anything, and they can’t see your notes or scripts. No account needed.
</p>
{link.state === 'loading' && <p className="text-sm text-[var(--r-sub)]">Checking…</p>}
{link.state === 'unavailable' && (
<p className="rounded-xl border border-[var(--r-line)] px-3 py-2 text-sm text-[var(--r-sub)]">Links need your account and a connection to the server. You can still share the text below.</p>
)}
{link.state === 'ready' && (link.token ? (
<div className="space-y-2">
<div className="flex items-center gap-2">
<input readOnly aria-label="Outline link" value={link.url} onFocus={(e) => e.target.select()}
className="min-w-0 flex-1 truncate rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-xs text-[var(--r-sub)]" />
<button type="button" onClick={copyLink} className="shrink-0 rounded-lg bg-[var(--r-accent)] px-4 py-2 text-xs font-medium text-[var(--r-on)]">{copied ? 'Copied!' : 'Copy'}</button>
</div>
<div className="flex flex-wrap items-center gap-3">
<button type="button" onClick={shareLink} className={btn}>Share link…</button>
<button type="button" onClick={link.turnOff} disabled={link.busy} className="text-xs text-[var(--r-danger,#b42318)] hover:underline">{link.busy ? 'Turning off…' : 'Turn off link'}</button>
</div>
<p className="text-[11px] text-[var(--r-sub)]">People see the version that last synced to your account, and it updates as you edit. Turning the link off stops it working right away.</p>
</div>
) : (
<button type="button" onClick={link.create} disabled={link.busy} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50">{link.busy ? 'Creating…' : 'Create read-only link'}</button>
))}
{link.error && <p role="alert" className="mt-2 text-xs text-[var(--r-danger,#b42318)]">{link.error}</p>}
<div className="mt-5 border-t border-[var(--r-line)] pt-4">
<h3 className="text-sm font-medium">Or send the text</h3>
<p className="mb-2 mt-1 text-xs text-[var(--r-sub)]">The outline you’re looking at, as plain text, to paste into a message or email.</p>
<button type="button" onClick={shareText} className={btn}>Share as text</button>
</div>
</Sheet>
);
}
+70
View File
@@ -0,0 +1,70 @@
// What someone sees when they open a read-only outline link: a calm page with the outline, a switcher
// when there is more than one, and Print / PDF. Nothing is editable.
import { useEffect, useState } from 'react';
import { getSharedOutline } from '../syncService.js';
import { themeVars } from './reader/readerThemes.js';
import OutlineDocument from './OutlineDocument.jsx';
import { printOutline } from '../utils/outlineExport.js';
import { flatten } from '../utils/outline.js';
const pill = (active) => `shrink-0 rounded-full px-3.5 py-1.5 text-sm transition ${active ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`;
function Frame({ children }) {
return <div className="min-h-dvh bg-[var(--r-bg)] font-sans text-[var(--r-text)]" style={themeVars('paper')}>{children}</div>;
}
export default function SharedOutlineView({ token }) {
const [data, setData] = useState(null);
const [error, setError] = useState('');
const [scope, setScope] = useState('study');
useEffect(() => {
let active = true;
getSharedOutline(token).then((result) => {
if (!active) return;
if (result.ok) { setData(result.data); setScope(result.data.outlines[0]?.scope ?? 'study'); } else setError(result.error ?? 'This outline link is invalid or has been turned off.');
});
return () => { active = false; };
}, [token]);
useEffect(() => { if (data?.title) document.title = `${data.title} — Outline`; }, [data]);
if (error) {
return (
<Frame>
<div className="flex min-h-dvh items-center justify-center px-5">
<div className="max-w-sm text-center">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--r-accent)]">Bible Study Project</p>
<p className="mt-4 text-base">{error}</p>
</div>
</div>
</Frame>
);
}
if (!data) return <Frame><p className="flex min-h-dvh items-center justify-center text-sm text-[var(--r-sub)]">Loading outline…</p></Frame>;
const current = data.outlines.find((o) => o.scope === scope) ?? data.outlines[0];
const title = current && current.scope !== 'study' ? `${data.title} — ${current.label}` : data.title;
const minutes = current ? flatten(current.tree).reduce((sum, { node }) => sum + (Number.isFinite(node.minutes) ? node.minutes : 0), 0) : 0;
const meta = current ? [current.scope === 'study' ? data.translation : `${current.label} · ${data.translation}`, minutes > 0 ? `About ${minutes} minute${minutes === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · ') : '';
const outline = current ? { title, subtitle: current.scope === 'study' ? data.translation : `${current.label} · ${data.translation}`, tree: current.tree } : null;
return (
<Frame>
<header className="sticky top-0 z-10 border-b border-[var(--r-line)] bg-[var(--r-bar)]/95 backdrop-blur">
<div className="mx-auto flex max-w-[44rem] flex-wrap items-center gap-x-3 gap-y-2 px-5 py-2.5 sm:px-8">
<span className="shrink-0 text-sm font-medium">Bible Study</span>
{data.outlines.length > 1 ? (
<nav aria-label="Outline" className="order-last flex w-full min-w-0 gap-1 overflow-x-auto sm:order-none sm:w-auto sm:flex-1">
{data.outlines.map((o) => (
<button key={o.scope} type="button" onClick={() => setScope(o.scope)} aria-current={current?.scope === o.scope ? 'true' : undefined} className={pill(current?.scope === o.scope)}>{o.label}</button>
))}
</nav>
) : <span className="flex-1" />}
{outline && <button type="button" onClick={() => printOutline(outline)} className="ml-auto shrink-0 sm:ml-0 rounded-full border border-[var(--r-line)] px-3.5 py-1.5 text-sm hover:bg-black/5">Print / PDF</button>}
</div>
</header>
{current ? <OutlineDocument title={title} meta={meta} tree={current.tree} /> : <p className="p-10 text-center text-sm text-[var(--r-sub)]">This study doesn’t have an outline yet.</p>}
</Frame>
);
}
+1
View File
@@ -15,6 +15,7 @@ const BY_PAGE = {
['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'],
],
+112
View File
@@ -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>
);
}
+88
View File
@@ -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('');
});
+5 -3
View File
@@ -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);
+17 -14
View File
@@ -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, CompareIcon } 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, onCompare, compareActive, 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,12 +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={onCompare} className={`${iconBtn} ${compareActive ? 'text-[var(--r-accent)]' : ''}`} aria-label="Compare translations" aria-pressed={!!compareActive} title="Compare translations"><CompareIcon /></button>
<button type="button" onClick={onSettings} className={`${iconBtn} w-10 text-sm font-medium`} aria-label="Reading settings">Aa</button>
<button type="button" onClick={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>
@@ -42,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}
@@ -54,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>
);
+6 -2
View File
@@ -18,7 +18,7 @@ export default function ReaderText({
title, translation, translationId, notice, verses, loading, error, layout, fontSize, wide,
font = 'serif', spacing = 'normal', showVerseNumbers = true, parallel = null, showDiff = true,
bookmarks, bookKey, chapter, selectedVerse, onSelectVerse, renderVerseText, searchLower,
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(
@@ -56,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>
);
+89 -6
View File
@@ -1,6 +1,6 @@
// 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';
@@ -8,6 +8,7 @@ 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']];
@@ -37,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 (
<>
{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={() => 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">
<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>
</>
);
}
@@ -99,10 +141,27 @@ function CompareTab({ book, chapter, verse, current }) {
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,
}) {
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;
@@ -134,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)}
@@ -145,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>
);
+1
View File
@@ -12,3 +12,4 @@ export const HeadphonesIcon = () => <svg {...base}><path d="M3.5 12V10a6.5 6.5 0
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>;
+1 -1
View File
@@ -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>
+44
View File
@@ -0,0 +1,44 @@
// The read-only link to a study's outlines: status, create, turn off, copy. A separate link from the
// whole-study share link, and it only ever shows outlines.
import { useEffect, useState } from 'react';
import { disableOutlineSharing, enableOutlineSharing, getOutlineShareStatus } from '../syncService.js';
export function useOutlineShareLink(projectId, enabled = true) {
const [token, setToken] = useState(null);
const [state, setState] = useState('loading'); // 'loading' | 'ready' | 'unavailable' (no account / server)
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
setToken(null);
setError('');
if (!projectId || !enabled) return undefined;
let active = true;
setState('loading');
getOutlineShareStatus(projectId).then((result) => {
if (!active) return;
if (result.ok) { setToken(result.data.shareToken); setState('ready'); } else setState('unavailable');
});
return () => { active = false; };
}, [projectId, enabled]);
const url = token ? `${window.location.origin}${window.location.pathname}?outline=${token}` : '';
const create = async () => {
setBusy(true);
setError('');
const result = await enableOutlineSharing(projectId);
setBusy(false);
if (result.ok) setToken(result.data.shareToken);
else setError(result.status === 404 ? 'Your study hasn’t synced to your account yet. Wait a moment and try again.' : 'Couldn’t create the link. Check your connection and try again.');
};
const turnOff = async () => {
setBusy(true);
const result = await disableOutlineSharing(projectId);
setBusy(false);
if (result.ok) setToken(null); else setError('Couldn’t turn the link off. Try again.');
};
return { token, url, state, busy, error, create, turnOff };
}
+145 -38
View File
@@ -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,39 +192,24 @@ 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.
<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>
<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 &amp; Hebrew word studies</li>
<li>Turn your notes into a script, then export or share</li>
<li>Your studies stay private to your account, with optional 2FA</li>
</ul>
<blockquote className="mt-8 border-l-2 border-[var(--r-line)] pl-4 text-sm italic text-[var(--r-sub)]">
"Make every effort to present yourself approved to God, an unashamed workman who accurately
handles the word of truth."
<footer className="mt-1 not-italic text-[var(--r-sub)]">— 2 Timothy 2:15 (BSB)</footer>
</blockquote>
<ThemeSwitch />
</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'}
</h1>
<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
@@ -151,10 +219,10 @@ export default function AuthPage() {
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)]"
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 font-medium text-[var(--r-text)]">
<label className="block text-sm">
Password
<input
type="password"
@@ -163,10 +231,11 @@ export default function AuthPage() {
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)]"
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 className="text-sm text-[var(--r-danger,#b42318)]">{authError}</p>}
{authError && <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{authError}</p>}
<button
type="submit"
disabled={authBusy}
@@ -183,9 +252,47 @@ export default function AuthPage() {
}}
className="mt-4 w-full text-center text-sm text-[var(--r-sub)] underline hover:text-[var(--r-text)]"
>
{authMode === 'login' ? "Need an account? Sign up" : 'Already have an account? Sign in'}
{authMode === 'login' ? 'Need an account? Sign up' : 'Already have an account? Sign in'}
</button>
</div>
<blockquote className="mt-6 hidden border-l-2 border-[var(--r-line)] pl-4 text-sm italic leading-relaxed text-[var(--r-sub)] lg:block">
“Make every effort to present yourself approved to God, an unashamed workman who accurately handles the word of truth.”
<footer className="mt-1 not-italic">— 2 Timothy 2:15 (BSB)</footer>
</blockquote>
</div>
</div>
{/* What the app does */}
<div className="lg:order-1">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--r-accent)]">Bible study, start to finish</p>
<h1 className="mt-3 text-4xl font-semibold leading-[1.1] tracking-tight sm:text-5xl">
Read it. Study it.<br />Teach it.
</h1>
<p className="mt-5 max-w-xl text-base leading-relaxed text-[var(--r-sub)]">
A place to read Scripture in six translations, work through a passage one chunk at a time, and turn what you learn into an outline,
a script, and something you can share.
</p>
<div className="mt-8 grid gap-4 sm:grid-cols-2">
<OutlinePreview />
<ComparePreview />
</div>
<div className="mt-12 grid gap-x-10 gap-y-9 sm:grid-cols-2">
{FEATURES.map((group) => (
<section key={group.title} aria-label={group.title}>
<h2 className="border-b border-[var(--r-line)] pb-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--r-accent)]">{group.title}</h2>
<ul className="mt-3 space-y-3.5">
{group.points.map(([name, text]) => (
<li key={name}>
<p className="text-[15px] font-medium">{name}</p>
<p className="mt-0.5 text-sm leading-relaxed text-[var(--r-sub)]">{text}</p>
</li>
))}
</ul>
</section>
))}
</div>
</div>
</div>
</div>
+5 -7
View File
@@ -10,7 +10,8 @@ import { formatChunkReference } from '../utils/bibleUtils.jsx';
import { buildChapterSummary } from '../utils/projectStorage.js';
import { compactPassages } from '../utils/passageSummary.js';
import { attachableFromChunk, fetchPassageText } from '../utils/passageText.js';
import { downloadOutlineDocx, downloadOutlineMarkdown, printOutline, shareOutline } from '../utils/outlineExport.js';
import ShareOutlineSheet from '../components/ShareOutlineSheet.jsx';
import { downloadOutlineDocx, downloadOutlineMarkdown, printOutline } from '../utils/outlineExport.js';
import {
KINDS, STUDY, addAfter, addChild, addRoot, chunkOf, countNodes, flatten, getNode, indent, insertAt, kindLabel, locate,
moveNode, newNode, outdent, outlineFor, outlineToText, removeNode, seedFromChunks, seedFromVerses, timeSummary, updateNode, withOutline,
@@ -44,6 +45,7 @@ export default function OutlinePage() {
const rows = flatten(tree);
const [pendingFocus, setPendingFocus] = useState(null);
const [metaOpen, setMetaOpen] = useState(() => new Set()); // points whose detail fields are showing
const [shareOpen, setShareOpen] = useState(false);
const [lookup, setLookup] = useState({ id: null, busy: false, error: '' });
// The one way every change is made, always from the latest outline (typing is fast).
@@ -107,11 +109,6 @@ export default function OutlinePage() {
const copy = async () => {
try { await navigator.clipboard.writeText(outlineToText(tree)); toast('Outline copied'); } catch { toast("Couldn't copy. Select the text and copy it instead.", { tone: 'error' }); }
};
const share = async () => {
const result = await shareOutline(outline);
if (result === 'copied') toast('Outline copied. Paste it into a message or email.');
else if (result === 'failed') toast("Couldn't share or copy the outline.", { tone: 'error' });
};
const print = () => { if (!printOutline(outline)) toast('Allow pop-ups for this site to print or save as PDF.', { tone: 'error' }); };
const word = async () => { try { await downloadOutlineDocx(outline); } catch { toast("Couldn't make the Word file.", { tone: 'error' }); } };
@@ -160,7 +157,7 @@ export default function OutlinePage() {
{seedLabel}{addable > 0 ? ` (${addable})` : ''}
</button>
<span className="mx-1 hidden h-6 w-px bg-[var(--r-line)] sm:block" aria-hidden="true" />
<button type="button" onClick={share} disabled={tree.length === 0} className={btn}>Share</button>
<button type="button" onClick={() => 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>
@@ -330,6 +327,7 @@ export default function OutlinePage() {
</p>
)}
</main>
<ShareOutlineSheet open={shareOpen} onClose={() => setShareOpen(false)} projectId={project.id} outline={outline} />
</StudioShell>
);
}
+25 -3
View File
@@ -102,10 +102,11 @@ 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);
const readerPageInkStrokes = readerInkByPage?.[`${readerBookAbbrev}_${readerChapter}`] ?? NO_STROKES;
@@ -274,6 +275,7 @@ export default function ReaderPage() {
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);
@@ -298,6 +300,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) } });
@@ -387,6 +401,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 = {
@@ -406,7 +421,9 @@ 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}
@@ -459,6 +476,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
@@ -528,6 +546,9 @@ export default function ReaderPage() {
onCycleColor={() => cycleBookmarkColor(selectedKey)}
onJump={jump}
onClose={() => setReaderSelectedVerse(null)}
request={xrefRequest}
verseHighlights={verseHighlights} colorLabels={readerColorLabels ?? {}}
onHighlightVerse={highlightVerse} onClearVerseHighlights={clearVerseHighlights}
/>
)}
@@ -578,7 +599,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}
+85 -1
View File
@@ -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 = () => {};
@@ -58,6 +59,89 @@ test('the compare button next to Aa opens the comparison sheet, and choosing a B
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('tapping a verse selects it', () => {
render(<ReaderPage />);
fireEvent.click(screen.getByText(/This man came to Jesus/));
+15
View File
@@ -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}`);
+33 -17
View File
@@ -334,23 +334,36 @@ function outlineListHtml(nodes, depth) {
// 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 = `
body { font-family: Georgia, serif; color: #0f172a; margin: 0; padding: 40px; }
.page { max-width: 760px; margin: auto; }
h1 { margin: 0 0 0.25rem; font-size: 1.7rem; }
.sub { color: #64748b; margin: 0 0 1.5rem; }
.outline ul { list-style: none; margin: 0.25rem 0 0.25rem 1.6rem; padding: 0; }
.outline > ul { margin-left: 0; }
.outline li { margin: 0.45rem 0; line-height: 1.5; }
.outline > ul > li { margin-top: 0.9rem; font-weight: 600; }
.outline > ul > li li { font-weight: 400; }
.lbl { display: inline-block; min-width: 2.1rem; font-weight: 700; }
.ref { color: #64748b; font-size: 0.9em; font-weight: 400; }
.kind { font-size: 0.72em; text-transform: uppercase; letter-spacing: 0.05em; color: #92400e; border: 1px solid #fcd34d; border-radius: 9999px; padding: 0 0.4em; margin-right: 0.3em; font-weight: 400; }
.o-note { margin: 0.2rem 0 0 2.1rem; color: #334155; font-size: 0.95em; font-weight: 400; }
.o-quote { margin: 0.3rem 0 0.3rem 2.1rem; padding-left: 0.75rem; border-left: 3px solid #cbd5e1; font-style: italic; font-weight: 400; }
.o-attach { margin-left: 2.1rem; font-size: 0.85em; color: #64748b; font-weight: 400; }
@media print { body { padding: 0; } li { break-inside: avoid; } }
: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">
@@ -362,9 +375,12 @@ export function buildOutlineHtml({ title, subtitle = '', tree }) {
</head>
<body>
<div class="page">
<p class="eyebrow">Outline</p>
<h1>${escHtml(title)}</h1>
<p class="sub">${escHtml([subtitle, total > 0 ? `About ${total} minute${total === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · '))}</p>
${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>`;
+36 -14
View File
@@ -75,33 +75,55 @@ 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 }) => [
export const outlineParagraphs = (tree) => flatten(tree).flatMap(({ node, depth, label }) => {
const main = depth === 0;
return [
new Paragraph({
indent: { left: 420 * depth, hanging: 420 },
spacing: { after: 60 },
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: DOCX_ACCENT }),
...(node.kind ? [new TextRun({ text: `${kindLabel(node.kind)}: `, italics: true, color: '92400E' })] : []),
new TextRun({ text: (node.text ?? '').trim() || '(untitled)' }),
...(node.ref ? [new TextRun({ text: ` (${node.ref})`, italics: true, color: '7A7060' })] : []),
...(Number.isFinite(node.minutes) && node.minutes > 0 ? [new TextRun({ text: ` · ${node.minutes} min`, color: '7A7060' })] : []),
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 })] : []),
],
}),
...detailLines(node).map((line) => new Paragraph({
indent: { left: 420 * depth + 420 },
// 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, italics: true, color: '4B5563', size: 20 })],
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 }),
new Paragraph({ children: [new TextRun({ text: [subtitle, timeSummary(tree)].filter(Boolean).join(' · '), italics: true, color: '7A7060' })], spacing: { after: 300 } }),
...(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: [{ children }] });
const doc = new Document({ styles: DOCX_STYLES, sections: [{ properties: { page: { margin: { top: 1200, bottom: 1200, left: 1400, right: 1400 } } }, children }] });
return Packer.toBlob(doc);
}
+10
View File
@@ -54,3 +54,13 @@ test('the printable page escapes text and shows notes', () => {
expect(html).toContain('&lt;i&gt;hi&lt;/i&gt;');
expect(html).toContain('class="o-note"');
});
test('the Word document builds, with and without the extras', async () => {
const { buildOutlineDocxBlob } = await import('./docxExport.js');
const blob = await buildOutlineDocxBlob({
...outline,
tree: [{ ...outline.tree[0], scripture: { ref: 'Titus 1:5', text: 'appoint elders', translation: 'BSB' }, attachments: [{ id: 'x', type: 'word', label: 'presbyteros', detail: 'elder' }] }, { id: 'z', text: '', ref: '', children: [] }],
});
expect(blob.size).toBeGreaterThan(1000);
expect((await buildOutlineDocxBlob({ title: 'Empty', tree: [] })).size).toBeGreaterThan(500);
});