Compare commits
No files matched your search
@@ -10,3 +10,4 @@ coverage/
|
|||||||
.api.pid
|
.api.pid
|
||||||
.api.log
|
.api.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.env
|
||||||
@@ -2,14 +2,14 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Bible Study Project</title>
|
<title>Bible Study Project</title>
|
||||||
|
|
||||||
<!-- PWA manifest -->
|
<!-- PWA manifest -->
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
|
||||||
<!-- Theme / status bar -->
|
<!-- Theme / status bar -->
|
||||||
<meta name="theme-color" content="#0f172a" />
|
<meta name="theme-color" content="#f1ece0" />
|
||||||
|
|
||||||
<!-- iOS PWA -->
|
<!-- iOS PWA -->
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
<!-- Fallback icon for browsers -->
|
<!-- Fallback icon for browsers -->
|
||||||
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
|
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-slate-50 text-slate-900">
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 694 B After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 757 B After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 420 KiB |
@@ -0,0 +1,18 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Bible Study Project">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="glow" cx="50%" cy="34%" r="45%">
|
||||||
|
<stop offset="0" stop-color="#fbbf24" stop-opacity=".35"/>
|
||||||
|
<stop offset="1" stop-color="#fbbf24" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="512" height="512" rx="112" fill="#0f172a"/>
|
||||||
|
<rect width="512" height="512" rx="112" fill="url(#glow)"/>
|
||||||
|
<!-- cross -->
|
||||||
|
<rect x="238" y="82" width="36" height="170" rx="6" fill="#fbbf24"/>
|
||||||
|
<rect x="200" y="122" width="112" height="34" rx="6" fill="#fbbf24"/>
|
||||||
|
<!-- open book -->
|
||||||
|
<path d="M256 392 C206 356 146 350 80 366 L80 262 C146 246 206 252 256 286 Z" fill="#f8fafc"/>
|
||||||
|
<path d="M256 392 C306 356 366 350 432 366 L432 262 C366 246 306 252 256 286 Z" fill="#e2e8f0"/>
|
||||||
|
<path d="M60 384 C136 366 206 374 256 414 C306 374 376 366 452 384" fill="none" stroke="#fbbf24" stroke-width="14" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M256 286 V392" stroke="#0f172a" stroke-width="6" opacity=".35"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -6,8 +6,8 @@
|
|||||||
"scope": "/",
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"orientation": "any",
|
"orientation": "any",
|
||||||
"background_color": "#f8fafc",
|
"background_color": "#faf7f0",
|
||||||
"theme_color": "#0f172a",
|
"theme_color": "#f1ece0",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/icon-192.png",
|
"src": "/icon-192.png",
|
||||||
@@ -19,7 +19,13 @@
|
|||||||
"src": "/icon-512.png",
|
"src": "/icon-512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any maskable"
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-maskable-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
Drop a cover photo here for each built-in reading plan, named exactly:
|
||||||
|
|
||||||
|
bible-year.jpg Whole Bible in a Year
|
||||||
|
bible-90.jpg Whole Bible in 90 Days
|
||||||
|
nt-90.jpg New Testament in 90 Days
|
||||||
|
nt-30.jpg New Testament in 30 Days
|
||||||
|
gospels-30.jpg The Gospels in 30 Days
|
||||||
|
psalms-30.jpg Psalms in 30 Days
|
||||||
|
proverbs-31.jpg Proverbs in 31 Days
|
||||||
|
pentateuch-60.jpg The Pentateuch in 60 Days
|
||||||
|
wisdom-40.jpg Wisdom Literature in 40 Days
|
||||||
|
|
||||||
|
Landscape orientation, ~1200px wide, works best (cards crop to a 16:9 box).
|
||||||
|
Any plan without a matching file just shows its colorful gradient + icon —
|
||||||
|
nothing breaks if some are missing.
|
||||||
|
|
||||||
|
See src/utils/readingPlans.js -> PIXABAY_SEARCH_LINKS for a Pixabay search
|
||||||
|
link per plan to help pick one.
|
||||||
|
After Width: | Height: | Size: 249 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 202 KiB |
@@ -1,6 +1,9 @@
|
|||||||
const SHELL = 'bible-shell-v1';
|
const SHELL = 'bible-shell-v2';
|
||||||
const API = 'bible-api-v1';
|
const API = 'bible-api-v1';
|
||||||
const BIBLE_ORIGINS = ['https://bible.helloao.org', 'https://bolls.life'];
|
// Chapter text, interlinear cross-refs, and (BSB) chapter audio all come from
|
||||||
|
// these origins — cache them so the reader, reading plans, and audio all keep
|
||||||
|
// working offline once you've visited a passage before.
|
||||||
|
const BIBLE_ORIGINS = ['https://bible.helloao.org', 'https://bolls.life', 'https://audio.bible.helloao.org'];
|
||||||
|
|
||||||
self.addEventListener('install', (e) => {
|
self.addEventListener('install', (e) => {
|
||||||
e.waitUntil(
|
e.waitUntil(
|
||||||
@@ -24,15 +27,23 @@ self.addEventListener('fetch', (e) => {
|
|||||||
const { request } = e;
|
const { request } = e;
|
||||||
const url = request.url;
|
const url = request.url;
|
||||||
|
|
||||||
// Never intercept API routes — let Express handle them
|
// Never intercept this app's own API routes — let Express handle them. (Only same-origin ones: the
|
||||||
if (new URL(url).pathname.startsWith('/api/')) return;
|
// Bible text at bible.helloao.org/api/... must pass through to the offline cache below.)
|
||||||
|
const { origin, pathname } = new URL(url);
|
||||||
|
if (origin === self.location.origin && pathname.startsWith('/api/')) return;
|
||||||
|
|
||||||
// External Bible API: network-first, fall back to cache
|
// External Bible API (text, cross-refs, audio): network-first, fall back to cache.
|
||||||
|
// Audio elements often issue Range requests, which come back as 206 Partial
|
||||||
|
// Content — the Cache API throws on those, so only cache full (200) responses.
|
||||||
if (BIBLE_ORIGINS.some(o => url.startsWith(o))) {
|
if (BIBLE_ORIGINS.some(o => url.startsWith(o))) {
|
||||||
e.respondWith(
|
e.respondWith(
|
||||||
fetch(request)
|
fetch(request)
|
||||||
.then(res => {
|
.then(res => {
|
||||||
if (res.ok) caches.open(API).then(c => c.put(request, res.clone()));
|
// Clone *now*: once the page starts reading the body a later clone() throws and nothing is saved.
|
||||||
|
if (res.status === 200) {
|
||||||
|
const copy = res.clone();
|
||||||
|
e.waitUntil(caches.open(API).then(c => c.put(request, copy)).catch(() => {}));
|
||||||
|
}
|
||||||
return res;
|
return res;
|
||||||
})
|
})
|
||||||
.catch(() =>
|
.catch(() =>
|
||||||
@@ -51,7 +62,10 @@ self.addEventListener('fetch', (e) => {
|
|||||||
e.respondWith(
|
e.respondWith(
|
||||||
fetch(request)
|
fetch(request)
|
||||||
.then(res => {
|
.then(res => {
|
||||||
if (res.ok) caches.open(SHELL).then(c => c.put(request, res.clone()));
|
if (res.ok) {
|
||||||
|
const copy = res.clone();
|
||||||
|
e.waitUntil(caches.open(SHELL).then(c => c.put(request, copy)).catch(() => {}));
|
||||||
|
}
|
||||||
return res;
|
return res;
|
||||||
})
|
})
|
||||||
.catch(() =>
|
.catch(() =>
|
||||||
@@ -70,7 +84,10 @@ self.addEventListener('fetch', (e) => {
|
|||||||
c.match(request).then(hit => {
|
c.match(request).then(hit => {
|
||||||
if (hit) return hit;
|
if (hit) return hit;
|
||||||
return fetch(request).then(res => {
|
return fetch(request).then(res => {
|
||||||
if (res.ok) c.put(request, res.clone());
|
if (res.ok) {
|
||||||
|
const copy = res.clone();
|
||||||
|
e.waitUntil(c.put(request, copy).catch(() => {}));
|
||||||
|
}
|
||||||
return res;
|
return res;
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
// Proxy for licensed translations (NLT, CSB, NASB) served by api.bible.
|
||||||
|
// The API key stays on the server (API_BIBLE_KEY, loaded from .env if present);
|
||||||
|
// the browser only ever talks to /api/bible/...
|
||||||
|
import { existsSync, readFileSync } from 'fs';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
// Minimal .env loader so the key doesn't have to be exported in every launcher
|
||||||
|
// (setup.sh, systemd, docker). Real environment variables win.
|
||||||
|
const envPath = join(__dirname, '..', '.env');
|
||||||
|
if (existsSync(envPath)) {
|
||||||
|
for (const line of readFileSync(envPath, 'utf8').split('\n')) {
|
||||||
|
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||||
|
if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2].replace(/^(['"])(.*)\1$/, '$2');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_BASE = 'https://rest.api.bible/v1';
|
||||||
|
|
||||||
|
// Canonical book order (index + 1 = the book number the ESV API uses in ranges).
|
||||||
|
const BOOK_ORDER = ['GEN','EXO','LEV','NUM','DEU','JOS','JDG','RUT','1SA','2SA','1KI','2KI','1CH','2CH','EZR','NEH','EST','JOB','PSA','PRO','ECC','SNG','ISA','JER','LAM','EZK','DAN','HOS','JOL','AMO','OBA','JON','MIC','NAM','HAB','ZEP','HAG','ZEC','MAL','MAT','MRK','LUK','JHN','ACT','ROM','1CO','2CO','GAL','EPH','PHP','COL','1TH','2TH','1TI','2TI','TIT','PHM','HEB','JAS','1PE','2PE','1JN','2JN','3JN','JUD','REV'];
|
||||||
|
|
||||||
|
// ESV search returns "Psalm 23:1"-style references; map the names back to abbreviations.
|
||||||
|
const ESV_NAMES = ['Genesis','Exodus','Leviticus','Numbers','Deuteronomy','Joshua','Judges','Ruth','1 Samuel','2 Samuel','1 Kings','2 Kings','1 Chronicles','2 Chronicles','Ezra','Nehemiah','Esther','Job','Psalm','Proverbs','Ecclesiastes','Song of Solomon','Isaiah','Jeremiah','Lamentations','Ezekiel','Daniel','Hosea','Joel','Amos','Obadiah','Jonah','Micah','Nahum','Habakkuk','Zephaniah','Haggai','Zechariah','Malachi','Matthew','Mark','Luke','John','Acts','Romans','1 Corinthians','2 Corinthians','Galatians','Ephesians','Philippians','Colossians','1 Thessalonians','2 Thessalonians','1 Timothy','2 Timothy','Titus','Philemon','Hebrews','James','1 Peter','2 Peter','1 John','2 John','3 John','Jude','Revelation'];
|
||||||
|
const ABBREV_BY_NAME = new Map(ESV_NAMES.map((n, i) => [n.toLowerCase(), BOOK_ORDER[i]]));
|
||||||
|
ABBREV_BY_NAME.set('psalms', 'PSA');
|
||||||
|
ABBREV_BY_NAME.set('song of songs', 'SNG');
|
||||||
|
|
||||||
|
export function parseEsvReference(ref) {
|
||||||
|
const m = String(ref).match(/^(.+?)\s+(\d+):(\d+)/);
|
||||||
|
const bookAbbrev = m && ABBREV_BY_NAME.get(m[1].toLowerCase());
|
||||||
|
return bookAbbrev ? { bookAbbrev, chapter: Number(m[2]), verse: Number(m[3]) } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LICENSED_TRANSLATIONS = {
|
||||||
|
ESV: { provider: 'esv', name: 'English Standard Version' },
|
||||||
|
NLT: { provider: 'api.bible', bibleId: 'd6e14a625393b4da-01', name: 'New Living Translation' },
|
||||||
|
CSB: { provider: 'api.bible', bibleId: 'a556c5305ee15c3f-01', name: 'Christian Standard Bible' },
|
||||||
|
NASB: { provider: 'api.bible', bibleId: 'b8ee27bcd1cae43a-01', name: 'New American Standard Bible (1995)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const CACHE_TTL = 24 * 60 * 60 * 1000;
|
||||||
|
const CACHE_MAX = 1000;
|
||||||
|
// Crossway limits how much ESV text an app may store, so ESV chapters are only
|
||||||
|
// held briefly and in small numbers (enough to absorb repeat loads).
|
||||||
|
const ESV_CACHE_TTL = 60 * 60 * 1000;
|
||||||
|
const ESV_CACHE_MAX = 20;
|
||||||
|
const cache = new Map(); // "NLT/PSA/23" -> { at, payload }
|
||||||
|
|
||||||
|
// api.bible returns plain text with inline "[12]" verse markers, poetry line
|
||||||
|
// breaks, and stray "#" / nbsp characters. Split it back into { number, text }.
|
||||||
|
export function parseChapterText(content) {
|
||||||
|
const clean = String(content ?? '')
|
||||||
|
.replace(/ /g, ' ')
|
||||||
|
.replace(/#/g, '');
|
||||||
|
const parts = clean.split(/\[(\d+)\]/);
|
||||||
|
const verses = [];
|
||||||
|
for (let i = 1; i < parts.length; i += 2) {
|
||||||
|
const text = parts[i + 1].replace(/\s+/g, ' ').trim().replace(/\s+,$/, '');
|
||||||
|
if (text) verses.push({ number: Number(parts[i]), text });
|
||||||
|
}
|
||||||
|
return verses;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerBibleRoutes(app, requireAuth) {
|
||||||
|
app.get('/api/bible/translations', requireAuth, (_req, res) => {
|
||||||
|
res.json(Object.entries(LICENSED_TRANSLATIONS).map(([id, t]) => ({
|
||||||
|
id, name: t.name, available: Boolean(t.provider === 'esv' ? process.env.ESV_API_KEY : process.env.API_BIBLE_KEY),
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Whole-Bible search for the licensed translations, proxied so the keys stay
|
||||||
|
// server-side. Results are { bookAbbrev, chapter, verse, text }; nothing is cached.
|
||||||
|
const SEARCH_PAGE_SIZE = 25;
|
||||||
|
app.get('/api/bible/:translation/search', requireAuth, async (req, res) => {
|
||||||
|
const id = req.params.translation.toUpperCase();
|
||||||
|
const translation = LICENSED_TRANSLATIONS[id];
|
||||||
|
const q = String(req.query.q ?? '').trim();
|
||||||
|
const page = Math.max(1, Math.min(200, parseInt(req.query.page, 10) || 1));
|
||||||
|
if (!translation) return res.status(404).json({ error: 'Unknown translation.' });
|
||||||
|
if (q.length < 2 || q.length > 100) return res.status(400).json({ error: 'Search must be 2 to 100 characters.' });
|
||||||
|
const isEsv = translation.provider === 'esv';
|
||||||
|
const apiKey = isEsv ? process.env.ESV_API_KEY : process.env.API_BIBLE_KEY;
|
||||||
|
if (!apiKey) return res.status(503).json({ error: `${isEsv ? 'ESV_API_KEY' : 'API_BIBLE_KEY'} is not configured on the server.` });
|
||||||
|
|
||||||
|
try {
|
||||||
|
let results;
|
||||||
|
let total;
|
||||||
|
if (isEsv) {
|
||||||
|
const qs = new URLSearchParams({ q, 'page-size': String(SEARCH_PAGE_SIZE), page: String(page) });
|
||||||
|
const upstream = await fetch(`https://api.esv.org/v3/passage/search/?${qs}`, { headers: { Authorization: `Token ${apiKey}` } });
|
||||||
|
if (upstream.status === 429) return res.status(429).json({ error: 'ESV rate limit reached. Try again in a minute.' });
|
||||||
|
if (!upstream.ok) return res.status(502).json({ error: `Bible provider returned ${upstream.status}.` });
|
||||||
|
const body = await upstream.json();
|
||||||
|
total = body.total_results ?? 0;
|
||||||
|
results = (body.results ?? []).map((r) => {
|
||||||
|
const ref = parseEsvReference(r.reference);
|
||||||
|
return ref && { ...ref, text: String(r.content ?? '').replace(/<[^>]+>/g, '').trim() };
|
||||||
|
}).filter(Boolean);
|
||||||
|
} else {
|
||||||
|
const qs = new URLSearchParams({ query: q, limit: String(SEARCH_PAGE_SIZE), offset: String((page - 1) * SEARCH_PAGE_SIZE), sort: 'canonical' });
|
||||||
|
const upstream = await fetch(`${API_BASE}/bibles/${translation.bibleId}/search?${qs}`, { headers: { 'api-key': apiKey } });
|
||||||
|
if (!upstream.ok) return res.status(502).json({ error: `Bible provider returned ${upstream.status}.` });
|
||||||
|
const body = await upstream.json();
|
||||||
|
total = body.data?.total ?? 0;
|
||||||
|
results = (body.data?.verses ?? []).map((v) => ({
|
||||||
|
bookAbbrev: v.bookId,
|
||||||
|
chapter: Number(String(v.chapterId).split('.')[1]),
|
||||||
|
verse: Number(String(v.id).split('.')[2]),
|
||||||
|
text: String(v.text ?? '').replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(),
|
||||||
|
})).filter((r) => r.bookAbbrev && r.chapter && r.verse);
|
||||||
|
}
|
||||||
|
res.json({ translation: id, query: q, page, total, hasMore: page * SEARCH_PAGE_SIZE < total, results });
|
||||||
|
} catch {
|
||||||
|
res.status(502).json({ error: 'Could not reach the Bible provider.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ESV chapter audio (read by David Cochran Heath). The ESV API answers with a
|
||||||
|
// redirect to a public mp3; hand that URL to the browser's <audio> element.
|
||||||
|
app.get('/api/bible/ESV/audio/:book/:chapter', requireAuth, async (req, res) => {
|
||||||
|
const { book, chapter } = req.params;
|
||||||
|
const bookNum = BOOK_ORDER.indexOf(book.toUpperCase()) + 1;
|
||||||
|
if (!bookNum || !/^\d{1,3}$/.test(chapter)) return res.status(400).json({ error: 'Invalid book or chapter.' });
|
||||||
|
if (!process.env.ESV_API_KEY) return res.status(503).json({ error: 'ESV_API_KEY is not configured on the server.' });
|
||||||
|
const prefix = String(bookNum).padStart(2, '0') + String(Number(chapter)).padStart(3, '0');
|
||||||
|
try {
|
||||||
|
const upstream = await fetch(`https://api.esv.org/v3/passage/audio/?q=${prefix}001-${prefix}999`, {
|
||||||
|
headers: { Authorization: `Token ${process.env.ESV_API_KEY}` }, redirect: 'manual',
|
||||||
|
});
|
||||||
|
const url = upstream.headers.get('location');
|
||||||
|
if (upstream.status === 429) return res.status(429).json({ error: 'ESV rate limit reached. Try again in a minute.' });
|
||||||
|
if (!url) return res.status(502).json({ error: 'ESV audio is not available for this chapter.' });
|
||||||
|
res.json({ url });
|
||||||
|
} catch {
|
||||||
|
res.status(502).json({ error: 'Could not reach the ESV audio service.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/bible/:translation/:book/:chapter', requireAuth, async (req, res) => {
|
||||||
|
const translation = LICENSED_TRANSLATIONS[req.params.translation.toUpperCase()];
|
||||||
|
const { book, chapter } = req.params;
|
||||||
|
if (!translation) return res.status(404).json({ error: 'Unknown translation.' });
|
||||||
|
if (!/^[1-3A-Z]{3}$/i.test(book) || !/^\d{1,3}$/.test(chapter)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid book or chapter.' });
|
||||||
|
}
|
||||||
|
const id = req.params.translation.toUpperCase();
|
||||||
|
const isEsv = translation.provider === 'esv';
|
||||||
|
const apiKey = isEsv ? process.env.ESV_API_KEY : process.env.API_BIBLE_KEY;
|
||||||
|
if (!apiKey) return res.status(503).json({ error: `${isEsv ? 'ESV_API_KEY' : 'API_BIBLE_KEY'} is not configured on the server.` });
|
||||||
|
|
||||||
|
const key = `${id}/${book.toUpperCase()}/${chapter}`;
|
||||||
|
const ttl = isEsv ? ESV_CACHE_TTL : CACHE_TTL;
|
||||||
|
const hit = cache.get(key);
|
||||||
|
if (hit && Date.now() - hit.at < ttl) return res.json(hit.payload);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let payload;
|
||||||
|
if (isEsv) {
|
||||||
|
const bookNum = BOOK_ORDER.indexOf(book.toUpperCase()) + 1;
|
||||||
|
if (!bookNum) return res.status(400).json({ error: 'Invalid book.' });
|
||||||
|
const prefix = String(bookNum).padStart(2, '0') + String(Number(chapter)).padStart(3, '0');
|
||||||
|
const qs = new URLSearchParams({
|
||||||
|
q: `${prefix}001-${prefix}999`,
|
||||||
|
'include-passage-references': 'false', 'include-verse-numbers': 'true', 'include-first-verse-numbers': 'true',
|
||||||
|
'include-footnotes': 'false', 'include-headings': 'false', 'include-short-copyright': 'false',
|
||||||
|
'include-passage-horizontal-lines': 'false', 'include-heading-horizontal-lines': 'false',
|
||||||
|
});
|
||||||
|
const upstream = await fetch(`https://api.esv.org/v3/passage/text/?${qs}`, { headers: { Authorization: `Token ${apiKey}` } });
|
||||||
|
if (upstream.status === 429) return res.status(429).json({ error: 'ESV rate limit reached. Try again in a minute.' });
|
||||||
|
if (!upstream.ok) return res.status(502).json({ error: `Bible provider returned ${upstream.status}.` });
|
||||||
|
const body = await upstream.json();
|
||||||
|
const verses = parseChapterText(body.passages?.[0]);
|
||||||
|
if (verses.length === 0) return res.status(404).json({ error: 'Chapter not found.' });
|
||||||
|
payload = { translation: id, verses, copyright: '', fumsToken: null };
|
||||||
|
} else {
|
||||||
|
const qs = new URLSearchParams({
|
||||||
|
'content-type': 'text', 'include-notes': 'false', 'include-titles': 'false',
|
||||||
|
'include-chapter-numbers': 'false', 'include-verse-numbers': 'true', 'include-verse-spans': 'false',
|
||||||
|
});
|
||||||
|
const upstream = await fetch(
|
||||||
|
`${API_BASE}/bibles/${translation.bibleId}/chapters/${book.toUpperCase()}.${chapter}?${qs}`,
|
||||||
|
{ headers: { 'api-key': apiKey } },
|
||||||
|
);
|
||||||
|
if (upstream.status === 404) return res.status(404).json({ error: 'Chapter not found.' });
|
||||||
|
if (!upstream.ok) return res.status(502).json({ error: `Bible provider returned ${upstream.status}.` });
|
||||||
|
const body = await upstream.json();
|
||||||
|
const verses = parseChapterText(body.data?.content);
|
||||||
|
if (verses.length === 0) return res.status(502).json({ error: 'Bible provider returned no verses.' });
|
||||||
|
payload = { translation: id, verses, copyright: body.data?.copyright ?? '', fumsToken: body.meta?.fumsToken ?? null };
|
||||||
|
}
|
||||||
|
const max = isEsv ? ESV_CACHE_MAX : CACHE_MAX;
|
||||||
|
if (cache.size >= CACHE_MAX + ESV_CACHE_MAX) cache.delete(cache.keys().next().value);
|
||||||
|
if (isEsv) {
|
||||||
|
const esvKeys = [...cache.keys()].filter((k) => k.startsWith('ESV/'));
|
||||||
|
if (esvKeys.length >= max) cache.delete(esvKeys[0]);
|
||||||
|
}
|
||||||
|
cache.set(key, { at: Date.now(), payload });
|
||||||
|
res.json(payload);
|
||||||
|
} catch {
|
||||||
|
res.status(502).json({ error: 'Could not reach the Bible provider.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { test, expect } from 'vitest';
|
||||||
|
import { parseChapterText, parseEsvReference } from './bibleApi.js';
|
||||||
|
|
||||||
|
test('parseChapterText splits inline verse markers and cleans provider artifacts', () => {
|
||||||
|
const text = ' [1] The Lord is my shepherd;\n I have what I need.\n [2] He lets me lie down #— in pastures, ,\n';
|
||||||
|
expect(parseChapterText(text)).toEqual([
|
||||||
|
{ number: 1, text: 'The Lord is my shepherd; I have what I need.' },
|
||||||
|
{ number: 2, text: 'He lets me lie down — in pastures,' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseChapterText ignores headings before the first verse', () => {
|
||||||
|
expect(parseChapterText('Psalm 23\n\nA Psalm of David.\n\n[1] The LORD is my shepherd;')).toEqual([
|
||||||
|
{ number: 1, text: 'The LORD is my shepherd;' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseEsvReference maps ESV book names back to abbreviations', () => {
|
||||||
|
expect(parseEsvReference('Psalm 23:1')).toEqual({ bookAbbrev: 'PSA', chapter: 23, verse: 1 });
|
||||||
|
expect(parseEsvReference('1 John 3:16-18')).toEqual({ bookAbbrev: '1JN', chapter: 3, verse: 16 });
|
||||||
|
expect(parseEsvReference('Song of Solomon 2:4')).toEqual({ bookAbbrev: 'SNG', chapter: 2, verse: 4 });
|
||||||
|
expect(parseEsvReference('Not a book 1:1')).toBeNull();
|
||||||
|
});
|
||||||
@@ -63,10 +63,30 @@ export function initDb() {
|
|||||||
db.exec('ALTER TABLE users ADD COLUMN podcast_name TEXT');
|
db.exec('ALTER TABLE users ADD COLUMN podcast_name TEXT');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Older databases predate ntfy.sh daily reading reminders.
|
||||||
|
if (!userCols.some((c) => c.name === 'ntfy_url')) {
|
||||||
|
db.exec(`
|
||||||
|
ALTER TABLE users ADD COLUMN ntfy_url TEXT;
|
||||||
|
ALTER TABLE users ADD COLUMN ntfy_topic TEXT;
|
||||||
|
ALTER TABLE users ADD COLUMN reminder_time TEXT;
|
||||||
|
ALTER TABLE users ADD COLUMN reminder_enabled INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE users ADD COLUMN last_reminder_date TEXT;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reminders fire at the user's own local time, so remember their timezone.
|
||||||
|
if (!userCols.some((c) => c.name === 'reminder_tz')) {
|
||||||
|
db.exec('ALTER TABLE users ADD COLUMN reminder_tz TEXT');
|
||||||
|
}
|
||||||
|
|
||||||
// Older databases predate shareable read-only links.
|
// Older databases predate shareable read-only links.
|
||||||
if (!projectCols.some((c) => c.name === 'share_token')) {
|
if (!projectCols.some((c) => c.name === 'share_token')) {
|
||||||
db.exec('ALTER TABLE projects ADD COLUMN share_token TEXT');
|
db.exec('ALTER TABLE projects ADD COLUMN share_token TEXT');
|
||||||
}
|
}
|
||||||
|
if (!projectCols.some((c) => c.name === 'outline_share_token')) {
|
||||||
|
db.exec('ALTER TABLE projects ADD COLUMN outline_share_token TEXT');
|
||||||
|
}
|
||||||
|
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_outline_share_token ON projects(outline_share_token) WHERE outline_share_token IS NOT NULL');
|
||||||
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_share_token ON projects(share_token) WHERE share_token IS NOT NULL');
|
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_share_token ON projects(share_token) WHERE share_token IS NOT NULL');
|
||||||
|
|
||||||
// Reader ink — one row per user + book + chapter, stored as JSON
|
// Reader ink — one row per user + book + chapter, stored as JSON
|
||||||
@@ -81,6 +101,35 @@ export function initDb() {
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Display preferences (theme, default translation, layout, ...) — one row per user.
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS user_prefs (
|
||||||
|
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Reader bookmarks + text highlights — one row per user (small JSON), so they follow the
|
||||||
|
// user across devices. updated_at lets a device tell whether the server copy is newer.
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS reader_marks (
|
||||||
|
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Reading plan progress — one row per user, holding the whole list of
|
||||||
|
// in-progress/completed plans as JSON (small; no need to normalize).
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS reading_progress (
|
||||||
|
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
console.log(`SQLite database ready at ${DB_PATH}`);
|
console.log(`SQLite database ready at ${DB_PATH}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +164,7 @@ function parseUserRow(row) {
|
|||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
totpEnabled: !!row.totpEnabled,
|
totpEnabled: !!row.totpEnabled,
|
||||||
|
reminderEnabled: !!row.reminderEnabled,
|
||||||
backupCodeHashes: row.backupCodesRaw ? JSON.parse(row.backupCodesRaw) : [],
|
backupCodeHashes: row.backupCodesRaw ? JSON.parse(row.backupCodesRaw) : [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -122,7 +172,9 @@ function parseUserRow(row) {
|
|||||||
const USER_SELECT = `
|
const USER_SELECT = `
|
||||||
SELECT id, email, password_hash AS passwordHash, created_at AS createdAt,
|
SELECT id, email, password_hash AS passwordHash, created_at AS createdAt,
|
||||||
totp_secret AS totpSecret, totp_enabled AS totpEnabled, backup_codes AS backupCodesRaw,
|
totp_secret AS totpSecret, totp_enabled AS totpEnabled, backup_codes AS backupCodesRaw,
|
||||||
podcast_name AS podcastName
|
podcast_name AS podcastName,
|
||||||
|
ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, reminder_time AS reminderTime,
|
||||||
|
reminder_enabled AS reminderEnabled, last_reminder_date AS lastReminderDate, reminder_tz AS reminderTz
|
||||||
FROM users
|
FROM users
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -153,6 +205,33 @@ export function setPodcastName(userId, podcastName) {
|
|||||||
db.prepare('UPDATE users SET podcast_name = ? WHERE id = ?').run(podcastName || null, userId);
|
db.prepare('UPDATE users SET podcast_name = ? WHERE id = ?').run(podcastName || null, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Daily reading reminders (sent via ntfy.sh — see server/reminders.js)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Saves a user's ntfy reminder settings ({ ntfyUrl, ntfyTopic, reminderTime, reminderEnabled }). */
|
||||||
|
export function setReminderSettings(userId, { ntfyUrl, ntfyTopic, reminderTime, reminderEnabled, reminderTz }) {
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE users SET ntfy_url = ?, ntfy_topic = ?, reminder_time = ?, reminder_enabled = ?, reminder_tz = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(ntfyUrl || null, ntfyTopic || null, reminderTime || null, reminderEnabled ? 1 : 0, reminderTz || null, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every account with reminders turned on and a topic configured — checked once a minute. */
|
||||||
|
export function getUsersWithRemindersEnabled() {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT id, email, ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, reminder_time AS reminderTime,
|
||||||
|
last_reminder_date AS lastReminderDate, reminder_tz AS reminderTz
|
||||||
|
FROM users
|
||||||
|
WHERE reminder_enabled = 1 AND ntfy_topic IS NOT NULL AND reminder_time IS NOT NULL
|
||||||
|
`).all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marks that a user's reminder was sent today, so it isn't sent twice. */
|
||||||
|
export function setLastReminderDate(userId, dateString) {
|
||||||
|
db.prepare('UPDATE users SET last_reminder_date = ? WHERE id = ?').run(dateString, userId);
|
||||||
|
}
|
||||||
|
|
||||||
/** Rewrites the remaining backup-code hashes after one is used (single-use codes). */
|
/** Rewrites the remaining backup-code hashes after one is used (single-use codes). */
|
||||||
export function setBackupCodeHashes(userId, backupCodeHashes) {
|
export function setBackupCodeHashes(userId, backupCodeHashes) {
|
||||||
db.prepare('UPDATE users SET backup_codes = ? WHERE id = ?').run(JSON.stringify(backupCodeHashes), userId);
|
db.prepare('UPDATE users SET backup_codes = ? WHERE id = ?').run(JSON.stringify(backupCodeHashes), userId);
|
||||||
@@ -172,6 +251,39 @@ export function getAllReaderInk(userId) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The user's synced preferences: { key: { v, at } } (empty when none saved yet). */
|
||||||
|
export function getUserPrefs(userId) {
|
||||||
|
const row = db.prepare('SELECT data FROM user_prefs WHERE user_id = ?').get(userId);
|
||||||
|
return row ? JSON.parse(row.data) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replaces the user's stored preferences with `prefs` ({ key: { v, at } }). */
|
||||||
|
export function setUserPrefs(userId, prefs) {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO user_prefs (user_id, data, updated_at) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
|
||||||
|
`).run(userId, JSON.stringify(prefs), Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The user's bookmarks + highlights: { bookmarks, highlights, updatedAt } (updatedAt 0 = never saved). */
|
||||||
|
export function getReaderMarks(userId) {
|
||||||
|
const row = db.prepare('SELECT data, updated_at AS updatedAt FROM reader_marks WHERE user_id = ?').get(userId);
|
||||||
|
if (!row) return { bookmarks: {}, highlights: [], updatedAt: 0 };
|
||||||
|
const data = JSON.parse(row.data);
|
||||||
|
return { bookmarks: data.bookmarks ?? {}, highlights: data.highlights ?? [], updatedAt: row.updatedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replaces the user's bookmarks + highlights; returns the new updatedAt (always moves forward). */
|
||||||
|
export function setReaderMarks(userId, { bookmarks, highlights }) {
|
||||||
|
const prev = db.prepare('SELECT updated_at AS updatedAt FROM reader_marks WHERE user_id = ?').get(userId);
|
||||||
|
const updatedAt = Math.max(Date.now(), (prev?.updatedAt ?? 0) + 1);
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO reader_marks (user_id, data, updated_at) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
|
||||||
|
`).run(userId, JSON.stringify({ bookmarks, highlights }), updatedAt);
|
||||||
|
return updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
/** Upserts the ink strokes for one reader page. */
|
/** Upserts the ink strokes for one reader page. */
|
||||||
export function setReaderInkPage(userId, bookAbbrev, chapter, strokes) {
|
export function setReaderInkPage(userId, bookAbbrev, chapter, strokes) {
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
@@ -182,6 +294,30 @@ export function setReaderInkPage(userId, bookAbbrev, chapter, strokes) {
|
|||||||
`).run(userId, bookAbbrev, Number(chapter), JSON.stringify(strokes), Date.now());
|
`).run(userId, bookAbbrev, Number(chapter), JSON.stringify(strokes), Date.now());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Reading plan progress — cross-device sync for started/completed reading plans
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Returns the user's reading-plan data ({ progress, customPlans }, parsed JSON), or an empty shell. */
|
||||||
|
export function getReadingProgress(userId) {
|
||||||
|
const row = db.prepare('SELECT data FROM reading_progress WHERE user_id = ?').get(userId);
|
||||||
|
if (!row) return { progress: [], customPlans: [] };
|
||||||
|
try {
|
||||||
|
return JSON.parse(row.data);
|
||||||
|
} catch {
|
||||||
|
return { progress: [], customPlans: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Overwrites the user's whole reading-plan progress list. */
|
||||||
|
export function setReadingProgress(userId, progress) {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO reading_progress (user_id, data, updated_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
|
||||||
|
`).run(userId, JSON.stringify(progress), Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assigns any pre-existing, unowned projects (from before multi-user support)
|
* Assigns any pre-existing, unowned projects (from before multi-user support)
|
||||||
* to the given user. Intended to run once, right after the first account is created.
|
* to the given user. Intended to run once, right after the first account is created.
|
||||||
@@ -221,6 +357,12 @@ export function getProject(id, userId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The server copy's lastEdited for a project owned by userId, or null when there isn't one. */
|
||||||
|
export function getProjectEdited(id, userId) {
|
||||||
|
const row = db.prepare('SELECT last_edited AS lastEdited FROM projects WHERE id = ? AND user_id = ?').get(id, userId);
|
||||||
|
return row ? row.lastEdited : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Insert or replace a project owned by userId.
|
* Insert or replace a project owned by userId.
|
||||||
* Returns the summary, or null if the id already belongs to a different user.
|
* Returns the summary, or null if the id already belongs to a different user.
|
||||||
@@ -276,6 +418,28 @@ export function clearShareToken(id, userId) {
|
|||||||
db.prepare('UPDATE projects SET share_token = NULL WHERE id = ? AND user_id = ?').run(id, userId);
|
db.prepare('UPDATE projects SET share_token = NULL WHERE id = ? AND user_id = ?').run(id, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- read-only outline links: a separate token, so sharing an outline never shares the whole study ----
|
||||||
|
|
||||||
|
export function getOutlineShareToken(id, userId) {
|
||||||
|
const row = db.prepare('SELECT outline_share_token AS t FROM projects WHERE id = ? AND user_id = ?').get(id, userId);
|
||||||
|
return row?.t ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setOutlineShareToken(id, userId, token) {
|
||||||
|
return db.prepare('UPDATE projects SET outline_share_token = ? WHERE id = ? AND user_id = ?').run(token, id, userId).changes > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearOutlineShareToken(id, userId) {
|
||||||
|
db.prepare('UPDATE projects SET outline_share_token = NULL WHERE id = ? AND user_id = ?').run(id, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public lookup: the project data behind an outline link (callers must only ever send the outlines on). */
|
||||||
|
export function getProjectByOutlineToken(token) {
|
||||||
|
const row = db.prepare('SELECT data FROM projects WHERE outline_share_token = ?').get(token);
|
||||||
|
if (!row) return null;
|
||||||
|
try { return JSON.parse(row.data); } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
/** Public lookup: returns the full project data for a valid share token, or null. No ownership check — this is the point. */
|
/** Public lookup: returns the full project data for a valid share token, or null. No ownership check — this is the point. */
|
||||||
export function getProjectByShareToken(token) {
|
export function getProjectByShareToken(token) {
|
||||||
const row = db.prepare('SELECT data FROM projects WHERE share_token = ?').get(token);
|
const row = db.prepare('SELECT data FROM projects WHERE share_token = ?').get(token);
|
||||||
|
|||||||
@@ -6,13 +6,16 @@ import { randomUUID } from 'crypto';
|
|||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { dirname, join } from 'path';
|
import { dirname, join } from 'path';
|
||||||
import {
|
import {
|
||||||
initDb, getAllProjects, getProject, upsertProject, deleteProject,
|
initDb, getAllProjects, getProject, getProjectEdited, upsertProject, deleteProject,
|
||||||
countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects,
|
countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects,
|
||||||
enableTotp, disableTotp, setBackupCodeHashes, setPodcastName,
|
enableTotp, disableTotp, setBackupCodeHashes, setPodcastName,
|
||||||
getShareToken, setShareToken, clearShareToken, getProjectByShareToken,
|
getShareToken, setShareToken, clearShareToken, getProjectByShareToken,
|
||||||
|
getOutlineShareToken, setOutlineShareToken, clearOutlineShareToken, getProjectByOutlineToken,
|
||||||
adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject,
|
adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject,
|
||||||
setUserPassword, destroyAllSessionsForUser,
|
setUserPassword, destroyAllSessionsForUser,
|
||||||
getAllReaderInk, setReaderInkPage,
|
getAllReaderInk, setReaderInkPage, getReaderMarks, setReaderMarks, getUserPrefs, setUserPrefs,
|
||||||
|
getReadingProgress, setReadingProgress,
|
||||||
|
setReminderSettings,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
import { SqliteSessionStore } from './sessionStore.js';
|
import { SqliteSessionStore } from './sessionStore.js';
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +23,22 @@ import {
|
|||||||
generateTotpSecret, totpKeyUri, verifyTotpToken,
|
generateTotpSecret, totpKeyUri, verifyTotpToken,
|
||||||
generateBackupCodes, hashBackupCodes, consumeBackupCode, generateTemporaryPassword,
|
generateBackupCodes, hashBackupCodes, consumeBackupCode, generateTemporaryPassword,
|
||||||
} from './auth.js';
|
} from './auth.js';
|
||||||
|
import { startReminderScheduler, sendTestReminder, isValidTimeZone } from './reminders.js';
|
||||||
|
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) {
|
||||||
|
return {
|
||||||
|
id: user.id, email: user.email, totpEnabled: user.totpEnabled, podcastName: user.podcastName ?? null,
|
||||||
|
isAdmin: isAdminEmail(user.email),
|
||||||
|
reminderEnabled: user.reminderEnabled, ntfyUrl: user.ntfyUrl ?? null, ntfyTopic: user.ntfyTopic ?? null,
|
||||||
|
reminderTime: user.reminderTime ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -107,7 +126,10 @@ app.post('/api/auth/register', async (req, res) => {
|
|||||||
req.session.regenerate((err) => {
|
req.session.regenerate((err) => {
|
||||||
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
||||||
req.session.userId = user.id;
|
req.session.userId = user.id;
|
||||||
res.json({ id: user.id, email: user.email, totpEnabled: false, podcastName: null, isAdmin: isAdminEmail(user.email) });
|
res.json({
|
||||||
|
id: user.id, email: user.email, totpEnabled: false, podcastName: null, isAdmin: isAdminEmail(user.email),
|
||||||
|
reminderEnabled: false, ntfyUrl: null, ntfyTopic: null, reminderTime: null,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('POST /api/auth/register error:', err);
|
console.error('POST /api/auth/register error:', err);
|
||||||
@@ -136,7 +158,7 @@ app.post('/api/auth/login', async (req, res) => {
|
|||||||
return res.json({ mfaRequired: true });
|
return res.json({ mfaRequired: true });
|
||||||
}
|
}
|
||||||
req.session.userId = user.id;
|
req.session.userId = user.id;
|
||||||
res.json({ id: user.id, email: user.email, totpEnabled: false, podcastName: user.podcastName ?? null, isAdmin: isAdminEmail(user.email) });
|
res.json(userPayload(user));
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('POST /api/auth/login error:', err);
|
console.error('POST /api/auth/login error:', err);
|
||||||
@@ -175,7 +197,7 @@ app.post('/api/auth/mfa/verify', async (req, res) => {
|
|||||||
req.session.regenerate((err) => {
|
req.session.regenerate((err) => {
|
||||||
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
||||||
req.session.userId = user.id;
|
req.session.userId = user.id;
|
||||||
res.json({ id: user.id, email: user.email, totpEnabled: true, podcastName: user.podcastName ?? null, isAdmin: isAdminEmail(user.email) });
|
res.json(userPayload(user));
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('POST /api/auth/mfa/verify error:', err);
|
console.error('POST /api/auth/mfa/verify error:', err);
|
||||||
@@ -193,10 +215,7 @@ app.post('/api/auth/logout', (req, res) => {
|
|||||||
app.get('/api/auth/me', (req, res) => {
|
app.get('/api/auth/me', (req, res) => {
|
||||||
const user = req.session?.userId ? getUserById(req.session.userId) : null;
|
const user = req.session?.userId ? getUserById(req.session.userId) : null;
|
||||||
if (!user) return res.status(401).json({ error: 'Not signed in.' });
|
if (!user) return res.status(401).json({ error: 'Not signed in.' });
|
||||||
res.json({
|
res.json(userPayload(user));
|
||||||
id: user.id, email: user.email, totpEnabled: user.totpEnabled,
|
|
||||||
podcastName: user.podcastName ?? null, isAdmin: isAdminEmail(user.email),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -213,6 +232,46 @@ app.patch('/api/auth/profile', requireAuth, (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PATCH /api/auth/reminder-settings — daily reading reminder via ntfy.sh
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
app.patch('/api/auth/reminder-settings', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const reminderEnabled = !!req.body?.reminderEnabled;
|
||||||
|
const ntfyTopic = String(req.body?.ntfyTopic ?? '').trim();
|
||||||
|
const ntfyUrl = String(req.body?.ntfyUrl ?? '').trim();
|
||||||
|
const reminderTime = String(req.body?.reminderTime ?? '').trim();
|
||||||
|
const reminderTz = isValidTimeZone(req.body?.reminderTz) ? String(req.body.reminderTz) : null;
|
||||||
|
|
||||||
|
if (ntfyTopic && !/^[A-Za-z0-9_-]{1,64}$/.test(ntfyTopic)) {
|
||||||
|
return res.status(400).json({ error: 'Topic can only contain letters, numbers, "-" and "_".' });
|
||||||
|
}
|
||||||
|
if (ntfyUrl && !/^https?:\/\/.{1,200}$/.test(ntfyUrl)) {
|
||||||
|
return res.status(400).json({ error: 'Server URL must start with http:// or https://.' });
|
||||||
|
}
|
||||||
|
if (reminderEnabled) {
|
||||||
|
if (!ntfyTopic) return res.status(400).json({ error: 'A topic is required to enable reminders.' });
|
||||||
|
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(reminderTime)) {
|
||||||
|
return res.status(400).json({ error: 'Reminder time must be in HH:MM format.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setReminderSettings(req.session.userId, {
|
||||||
|
ntfyUrl: ntfyUrl || null,
|
||||||
|
ntfyTopic: ntfyTopic || null,
|
||||||
|
reminderTime: reminderTime || null,
|
||||||
|
reminderEnabled,
|
||||||
|
reminderTz,
|
||||||
|
});
|
||||||
|
res.json({
|
||||||
|
reminderEnabled, ntfyUrl: ntfyUrl || null, ntfyTopic: ntfyTopic || null, reminderTime: reminderTime || null,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('PATCH /api/auth/reminder-settings error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to save reminder settings.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// POST /api/auth/change-password — self-service password change (Account Settings)
|
// POST /api/auth/change-password — self-service password change (Account Settings)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -342,6 +401,11 @@ app.put('/api/projects/:id', requireAuth, (req, res) => {
|
|||||||
}
|
}
|
||||||
if (body.id.length > 100) return res.status(400).json({ error: 'Invalid project id.' });
|
if (body.id.length > 100) return res.status(400).json({ error: 'Invalid project id.' });
|
||||||
if (body.title.length > 500) return res.status(400).json({ error: 'Project title is too long.' });
|
if (body.title.length > 500) return res.status(400).json({ error: 'Project title is too long.' });
|
||||||
|
// Refuse to overwrite a copy another device saved after the one this device last saw
|
||||||
|
const stored = getProjectEdited(body.id, req.session.userId);
|
||||||
|
if (isSaveConflict({ stored, base: parseBase(req.get('X-Base-Edited')), force: req.query.force === '1' })) {
|
||||||
|
return res.status(409).json({ error: 'This study was changed on another device.', code: 'conflict', serverEdited: stored });
|
||||||
|
}
|
||||||
const saved = upsertProject(body, req.session.userId);
|
const saved = upsertProject(body, req.session.userId);
|
||||||
if (!saved) {
|
if (!saved) {
|
||||||
return res.status(403).json({ error: 'That project belongs to a different account.' });
|
return res.status(403).json({ error: 'That project belongs to a different account.' });
|
||||||
@@ -394,6 +458,108 @@ app.put('/api/reader/ink/:book/:chapter', requireAuth, (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/prefs — the user's synced display preferences ({ prefs: { key: { v, at } } })
|
||||||
|
app.get('/api/prefs', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json({ prefs: getUserPrefs(req.session.userId) });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('GET /api/prefs error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to load preferences.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/prefs — merge changed preferences in (newest change per key wins); returns the result
|
||||||
|
app.put('/api/prefs', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const merged = mergePrefs(getUserPrefs(req.session.userId), cleanPrefsPatch(req.body));
|
||||||
|
setUserPrefs(req.session.userId, merged);
|
||||||
|
res.json({ prefs: merged });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('PUT /api/prefs error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to save preferences.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/reader/marks — the user's bookmarks + highlights, with an updatedAt to compare against
|
||||||
|
app.get('/api/reader/marks', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(getReaderMarks(req.session.userId));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('GET /api/reader/marks error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to load bookmarks and highlights.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/reader/marks — replace the user's bookmarks + highlights ({ bookmarks, highlights })
|
||||||
|
app.put('/api/reader/marks', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const marks = cleanMarks(req.body);
|
||||||
|
if (!marks) return res.status(400).json({ error: 'Body must be an object.' });
|
||||||
|
res.json({ ok: true, updatedAt: setReaderMarks(req.session.userId, marks) });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('PUT /api/reader/marks error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to save bookmarks and highlights.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Reading plan progress — cross-device sync for started/completed reading plans
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// GET /api/reading-plans — the current user's whole progress list
|
||||||
|
registerBibleRoutes(app, requireAuth);
|
||||||
|
|
||||||
|
app.get('/api/reading-plans', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(getReadingProgress(req.session.userId));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('GET /api/reading-plans error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to load reading plan progress.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/reading-plans — overwrite the current user's whole progress list
|
||||||
|
// (an object: { progress: [...], customPlans: [...] })
|
||||||
|
app.put('/api/reading-plans', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!req.body || typeof req.body !== 'object' || Array.isArray(req.body)) {
|
||||||
|
return res.status(400).json({ error: 'Body must be an object.' });
|
||||||
|
}
|
||||||
|
setReadingProgress(req.session.userId, req.body);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('PUT /api/reading-plans error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to save reading plan progress.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/auth/reminder-test — sends a test notification to the topic in the request
|
||||||
|
// body (so it can be tried before saving), or to the saved settings.
|
||||||
|
const _lastReminderTest = new Map(); // userId -> timestamp, to keep test sends from being spammed
|
||||||
|
app.post('/api/auth/reminder-test', requireAuth, async (req, res) => {
|
||||||
|
const userId = req.session.userId;
|
||||||
|
if (Date.now() - (_lastReminderTest.get(userId) ?? 0) < 5000) {
|
||||||
|
return res.status(429).json({ error: 'Wait a few seconds before sending another test.' });
|
||||||
|
}
|
||||||
|
_lastReminderTest.set(userId, Date.now());
|
||||||
|
const saved = getUserById(userId);
|
||||||
|
const ntfyTopic = String(req.body?.ntfyTopic ?? saved?.ntfyTopic ?? '').trim();
|
||||||
|
const ntfyUrl = String(req.body?.ntfyUrl ?? saved?.ntfyUrl ?? '').trim();
|
||||||
|
if (!ntfyTopic) return res.status(400).json({ error: 'Enter a topic first.' });
|
||||||
|
if (!/^[A-Za-z0-9_-]{1,64}$/.test(ntfyTopic)) {
|
||||||
|
return res.status(400).json({ error: 'Topic can only contain letters, numbers, "-" and "_".' });
|
||||||
|
}
|
||||||
|
if (ntfyUrl && !/^https?:\/\/.{1,200}$/.test(ntfyUrl)) {
|
||||||
|
return res.status(400).json({ error: 'Server URL must start with http:// or https://.' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await sendTestReminder({ ntfyTopic, ntfyUrl });
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(502).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Read-only share links
|
// Read-only share links
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -435,6 +601,50 @@ app.delete('/api/projects/:id/share', requireAuth, (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Read-only outline links. A separate token from the study share link, and the public endpoint returns
|
||||||
|
// only the outlines (see outlineShare.js), never the notes or scripts.
|
||||||
|
app.get('/api/projects/:id/outline-share', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json({ shareToken: getOutlineShareToken(req.params.id, req.session.userId) });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('GET /api/projects/:id/outline-share error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to load share status.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/projects/:id/outline-share', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const token = getOutlineShareToken(req.params.id, req.session.userId) || randomUUID().replace(/-/g, '');
|
||||||
|
if (!setOutlineShareToken(req.params.id, req.session.userId, token)) return res.status(404).json({ error: 'Project not found.' });
|
||||||
|
res.json({ shareToken: token });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('POST /api/projects/:id/outline-share error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to enable sharing.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/projects/:id/outline-share', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
clearOutlineShareToken(req.params.id, req.session.userId);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('DELETE /api/projects/:id/outline-share error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to revoke sharing.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/share-outline/:token — PUBLIC, no login required
|
||||||
|
app.get('/api/share-outline/:token', (req, res) => {
|
||||||
|
try {
|
||||||
|
const project = getProjectByOutlineToken(req.params.token);
|
||||||
|
if (!project) return res.status(404).json({ error: 'This outline link is invalid or has been turned off.' });
|
||||||
|
res.json(buildOutlinePayload(project));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('GET /api/share-outline/:token error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to load the shared outline.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// GET /api/share/:token — PUBLIC, no login required: fetch a shared project read-only
|
// GET /api/share/:token — PUBLIC, no login required: fetch a shared project read-only
|
||||||
app.get('/api/share/:token', (req, res) => {
|
app.get('/api/share/:token', (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -545,6 +755,7 @@ if (isProd) {
|
|||||||
// Start
|
// Start
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
initDb();
|
initDb();
|
||||||
|
startReminderScheduler();
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Bible Study API running on http://localhost:${PORT}`);
|
console.log(`Bible Study API running on http://localhost:${PORT}`);
|
||||||
if (isProd) {
|
if (isProd) {
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// What a read-only outline link shows: the outlines of a study (the whole-study one and each chunk's)
|
||||||
|
// and nothing else — no observations, interpretations, applications, scripts or word notes. The
|
||||||
|
// outline data comes from the browser, so it is rebuilt field by field instead of passed through.
|
||||||
|
const MAX_NODES = 2000;
|
||||||
|
const MAX_DEPTH = 8;
|
||||||
|
const KINDS = new Set(['', 'illustration', 'application', 'quote', 'question', 'transition']);
|
||||||
|
|
||||||
|
const str = (v, max) => (typeof v === 'string' ? v.slice(0, max) : '');
|
||||||
|
|
||||||
|
function cleanTree(nodes, depth, counter) {
|
||||||
|
if (!Array.isArray(nodes) || depth > MAX_DEPTH) return [];
|
||||||
|
const out = [];
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (!n || typeof n !== 'object' || counter.n >= MAX_NODES) continue;
|
||||||
|
counter.n += 1;
|
||||||
|
const minutes = Number.isFinite(n.minutes) && n.minutes >= 0 && n.minutes <= 600 ? n.minutes : null;
|
||||||
|
out.push({
|
||||||
|
id: str(n.id, 64) || `n${counter.n}`,
|
||||||
|
text: str(n.text, 2000),
|
||||||
|
ref: str(n.ref, 200),
|
||||||
|
kind: KINDS.has(n.kind) ? n.kind : '',
|
||||||
|
minutes,
|
||||||
|
note: str(n.note, 8000),
|
||||||
|
scripture: n.scripture && typeof n.scripture.text === 'string'
|
||||||
|
? { ref: str(n.scripture.ref, 200), text: str(n.scripture.text, 6000), translation: str(n.scripture.translation, 12) }
|
||||||
|
: null,
|
||||||
|
attachments: (Array.isArray(n.attachments) ? n.attachments : []).slice(0, 50)
|
||||||
|
.filter((a) => a && typeof a === 'object')
|
||||||
|
.map((a) => ({ id: str(a.id, 100), type: a.type === 'word' ? 'word' : 'xref', label: str(a.label, 200), ...(a.detail ? { detail: str(a.detail, 500) } : {}) })),
|
||||||
|
children: cleanTree(n.children, depth + 1, counter),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunkLabel = (chapters, chapterIndex, chunk) => {
|
||||||
|
const ch = chapters[chapterIndex];
|
||||||
|
const next = chapters[chapterIndex + 1];
|
||||||
|
if (Number.isInteger(chunk.spilloverEndVerse) && next && next.bookAbbrev === ch.bookAbbrev) {
|
||||||
|
return `${ch.book} ${ch.chapter}:${chunk.startVerse}–${next.chapter}:${chunk.spilloverEndVerse}`;
|
||||||
|
}
|
||||||
|
return chunk.startVerse === chunk.endVerse ? `${ch.book} ${ch.chapter}:${chunk.startVerse}` : `${ch.book} ${ch.chapter}:${chunk.startVerse}–${chunk.endVerse}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// -> { title, translation, outlines: [{ scope, label, tree }] } with only the outlines that have points
|
||||||
|
export function buildOutlinePayload(project) {
|
||||||
|
const counter = { n: 0 };
|
||||||
|
const outlines = [];
|
||||||
|
const whole = cleanTree(project?.outline, 0, counter);
|
||||||
|
if (whole.length > 0) outlines.push({ scope: 'study', label: 'Whole study', tree: whole });
|
||||||
|
const chapters = Array.isArray(project?.chapters) ? project.chapters : [];
|
||||||
|
chapters.forEach((ch, chapterIndex) => {
|
||||||
|
for (const chunk of Array.isArray(ch?.chunks) ? ch.chunks : []) {
|
||||||
|
const tree = cleanTree(chunk?.outline, 0, counter);
|
||||||
|
if (tree.length > 0) outlines.push({ scope: str(chunk.id, 64), label: str(chunkLabel(chapters, chapterIndex, chunk), 200), tree });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { title: str(project?.title, 500), translation: str(project?.translation, 12) || 'BSB', outlines };
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { expect, test } from 'vitest';
|
||||||
|
import { buildOutlinePayload } from './outlineShare.js';
|
||||||
|
|
||||||
|
const node = (id, extra = {}) => ({ id, text: id, ref: '', kind: '', minutes: null, note: '', scripture: null, attachments: [], children: [], ...extra });
|
||||||
|
const project = {
|
||||||
|
title: 'Titus', translation: 'BSB',
|
||||||
|
outline: [node('a', { minutes: 5, kind: 'illustration', children: [node('a1')] })],
|
||||||
|
chapters: [{
|
||||||
|
book: 'Titus', bookAbbrev: 'TIT', chapter: '1', verses: [{ number: 1, text: 'secret verse text' }],
|
||||||
|
chunks: [
|
||||||
|
{ id: 'c1', startVerse: 1, endVerse: 4, observation: 'PRIVATE OBSERVATION', finalScript: 'PRIVATE SCRIPT', greekWords: [{ lexeme: 'x' }], outline: [node('b')] },
|
||||||
|
{ id: 'c2', startVerse: 5, endVerse: 5, observation: 'more private', outline: [] },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
test('shares the outlines only, with each chunk labelled by its passage', () => {
|
||||||
|
const p = buildOutlinePayload(project);
|
||||||
|
expect(p.title).toBe('Titus');
|
||||||
|
expect(p.outlines.map((o) => [o.scope, o.label])).toEqual([['study', 'Whole study'], ['c1', 'Titus 1:1–4']]);
|
||||||
|
expect(p.outlines[0].tree[0]).toMatchObject({ text: 'a', minutes: 5, kind: 'illustration' });
|
||||||
|
expect(p.outlines[0].tree[0].children[0].text).toBe('a1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nothing from the rest of the study leaks into what is shared', () => {
|
||||||
|
const json = JSON.stringify(buildOutlinePayload(project));
|
||||||
|
for (const secret of ['PRIVATE OBSERVATION', 'PRIVATE SCRIPT', 'secret verse text', 'more private', 'greekWords']) expect(json).not.toContain(secret);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown fields on a point are dropped, and bad values are cleaned', () => {
|
||||||
|
const p = buildOutlinePayload({ title: 'T', outline: [{ id: 'x', text: 'hi', evil: '<script>', kind: 'bogus', minutes: -4, scripture: { text: 5 }, attachments: [{ type: 'word', label: 'L' }, null], children: 'nope' }], chapters: [] });
|
||||||
|
expect(p.outlines[0].tree[0]).toEqual({
|
||||||
|
id: 'x', text: 'hi', ref: '', kind: '', minutes: null, note: '', scripture: null,
|
||||||
|
attachments: [{ id: '', type: 'word', label: 'L' }], children: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a study with no outlines shares none, and a huge outline is capped', () => {
|
||||||
|
expect(buildOutlinePayload({ title: 'T', chapters: [] }).outlines).toEqual([]);
|
||||||
|
const big = { title: 'T', outline: Array.from({ length: 3000 }, (_, i) => node(`n${i}`)), chapters: [] };
|
||||||
|
expect(buildOutlinePayload(big).outlines[0].tree).toHaveLength(2000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// A user's synced display preferences: theme, default Reader translation, verse layout, the
|
||||||
|
// commentary they last chose, and where they left off in the Reader. Each preference carries
|
||||||
|
// its own timestamp ({ v, at }) so the most recent change to each one wins, device by device.
|
||||||
|
const ENUMS = {
|
||||||
|
theme: ['paper', 'sepia', 'dark'],
|
||||||
|
translation: ['BSB', 'ESV', 'NLT', 'CSB', 'NASB', 'NET'],
|
||||||
|
layout: ['flow', 'verses'],
|
||||||
|
startPage: ['studies', 'reader', 'plans'], // where the app opens and where Home buttons go
|
||||||
|
planTranslation: ['same', 'BSB', 'ESV', 'NLT', 'CSB', 'NASB', 'NET'], // which Bible new reading plans start in
|
||||||
|
commentarySource: ['matthew-henry', 'john-gill', 'jamieson-fausset-brown', 'tyndale', 'adam-clarke', 'john-calvin', 'keil-delitzsch'],
|
||||||
|
};
|
||||||
|
const DAY_MS = 86_400_000;
|
||||||
|
const COLOR = /^#[0-9a-fA-F]{3,8}$/;
|
||||||
|
|
||||||
|
// A cleaned value, or undefined when it isn't acceptable for that preference.
|
||||||
|
export function cleanPrefValue(key, value) {
|
||||||
|
if (ENUMS[key]) return ENUMS[key].includes(value) ? value : undefined;
|
||||||
|
if (key === 'colorLabels') {
|
||||||
|
// What each highlight colour means to the reader: { "#fef08a": "Promises", ... }
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||||||
|
const out = {};
|
||||||
|
for (const [color, label] of Object.entries(value).slice(0, 12)) {
|
||||||
|
if (COLOR.test(color) && typeof label === 'string' && label.trim()) out[color] = label.trim().slice(0, 30);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (key === 'position') {
|
||||||
|
if (!value || typeof value !== 'object') return undefined;
|
||||||
|
const book = String(value.book ?? '').toUpperCase();
|
||||||
|
if (!/^[1-3A-Z]{3}$/.test(book) || !Number.isInteger(value.chapter) || value.chapter < 1 || value.chapter > 200) return undefined;
|
||||||
|
return { book, chapter: value.chapter };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Body -> { key: { v, at } } with only known keys, valid values and sane timestamps.
|
||||||
|
export function cleanPrefsPatch(body, now = Date.now()) {
|
||||||
|
const out = {};
|
||||||
|
const prefs = body?.prefs;
|
||||||
|
if (!prefs || typeof prefs !== 'object' || Array.isArray(prefs)) return out;
|
||||||
|
for (const [key, entry] of Object.entries(prefs)) {
|
||||||
|
const v = cleanPrefValue(key, entry?.v);
|
||||||
|
const at = entry?.at;
|
||||||
|
if (v === undefined || !Number.isFinite(at) || at < 0 || at > now + DAY_MS) continue;
|
||||||
|
out[key] = { v, at };
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per key, the entry with the newer timestamp wins (the stored one on a tie).
|
||||||
|
export function mergePrefs(existing = {}, patch = {}) {
|
||||||
|
const merged = { ...existing };
|
||||||
|
for (const [key, entry] of Object.entries(patch)) {
|
||||||
|
if (!merged[key] || entry.at > merged[key].at) merged[key] = entry;
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { mkdtempSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { test, expect } from 'vitest';
|
||||||
|
import { cleanPrefValue, cleanPrefsPatch, mergePrefs } from './prefs.js';
|
||||||
|
|
||||||
|
const NOW = 1_800_000_000_000;
|
||||||
|
|
||||||
|
test('only known preferences with valid values are accepted', () => {
|
||||||
|
expect(cleanPrefValue('theme', 'dark')).toBe('dark');
|
||||||
|
expect(cleanPrefValue('theme', 'neon')).toBeUndefined();
|
||||||
|
expect(cleanPrefValue('translation', 'ESV')).toBe('ESV');
|
||||||
|
expect(cleanPrefValue('translation', 'KJV')).toBeUndefined();
|
||||||
|
expect(cleanPrefValue('layout', 'flow')).toBe('flow');
|
||||||
|
expect(cleanPrefValue('commentarySource', 'tyndale')).toBe('tyndale');
|
||||||
|
expect(cleanPrefValue('startPage', 'reader')).toBe('reader');
|
||||||
|
expect(cleanPrefValue('startPage', 'admin')).toBeUndefined();
|
||||||
|
expect(cleanPrefValue('planTranslation', 'same')).toBe('same');
|
||||||
|
expect(cleanPrefValue('planTranslation', 'NASB')).toBe('NASB');
|
||||||
|
expect(cleanPrefValue('planTranslation', 'KJV')).toBeUndefined();
|
||||||
|
expect(cleanPrefValue('fontSize', 2)).toBeUndefined(); // not a synced preference
|
||||||
|
});
|
||||||
|
|
||||||
|
test('position needs a real book and chapter, and the book is normalised', () => {
|
||||||
|
expect(cleanPrefValue('position', { book: 'jhn', chapter: 3 })).toEqual({ book: 'JHN', chapter: 3 });
|
||||||
|
expect(cleanPrefValue('position', { book: 'JOHN', chapter: 3 })).toBeUndefined();
|
||||||
|
expect(cleanPrefValue('position', { book: 'JHN', chapter: 0 })).toBeUndefined();
|
||||||
|
expect(cleanPrefValue('position', { book: 'JHN', chapter: '3' })).toBeUndefined();
|
||||||
|
expect(cleanPrefValue('position', null)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cleanPrefsPatch drops unknown keys, bad values and silly timestamps', () => {
|
||||||
|
const patch = cleanPrefsPatch({ prefs: {
|
||||||
|
theme: { v: 'sepia', at: NOW },
|
||||||
|
layout: { v: 'nope', at: NOW },
|
||||||
|
evil: { v: 'x', at: NOW },
|
||||||
|
translation: { v: 'NLT', at: 'yesterday' },
|
||||||
|
commentarySource: { v: 'tyndale', at: NOW + 5 * 86_400_000 }, // far in the future
|
||||||
|
position: { v: { book: 'GEN', chapter: 2 }, at: NOW - 10 },
|
||||||
|
} }, NOW);
|
||||||
|
expect(patch).toEqual({ theme: { v: 'sepia', at: NOW }, position: { v: { book: 'GEN', chapter: 2 }, at: NOW - 10 } });
|
||||||
|
expect(cleanPrefsPatch(null)).toEqual({});
|
||||||
|
expect(cleanPrefsPatch({ prefs: [] })).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the newest change to each preference wins, independently', () => {
|
||||||
|
const existing = { theme: { v: 'dark', at: 100 }, layout: { v: 'flow', at: 500 } };
|
||||||
|
const patch = { theme: { v: 'paper', at: 200 }, layout: { v: 'verses', at: 300 }, translation: { v: 'ESV', at: 50 } };
|
||||||
|
expect(mergePrefs(existing, patch)).toEqual({
|
||||||
|
theme: { v: 'paper', at: 200 }, // patch is newer
|
||||||
|
layout: { v: 'flow', at: 500 }, // stored is newer
|
||||||
|
translation: { v: 'ESV', at: 50 }, // new key
|
||||||
|
});
|
||||||
|
expect(mergePrefs({ theme: { v: 'dark', at: 100 } }, { theme: { v: 'paper', at: 100 } }).theme.v).toBe('dark'); // tie keeps stored
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the database keeps one row per user', async () => {
|
||||||
|
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), 'prefs-'));
|
||||||
|
const db = await import('./db.js');
|
||||||
|
db.initDb();
|
||||||
|
db.createUser({ id: 'u1', email: 'a@example.test', passwordHash: 'x' });
|
||||||
|
db.createUser({ id: 'u2', email: 'b@example.test', passwordHash: 'x' });
|
||||||
|
expect(db.getUserPrefs('u1')).toEqual({});
|
||||||
|
db.setUserPrefs('u1', { theme: { v: 'dark', at: 1 } });
|
||||||
|
db.setUserPrefs('u1', { theme: { v: 'sepia', at: 2 } });
|
||||||
|
expect(db.getUserPrefs('u1')).toEqual({ theme: { v: 'sepia', at: 2 } });
|
||||||
|
expect(db.getUserPrefs('u2')).toEqual({});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Stops one device from silently overwriting a study another device has saved since. The browser sends
|
||||||
|
// the `lastEdited` of the server copy it last saw (X-Base-Edited); if the server's copy has moved past
|
||||||
|
// that, the save is refused with 409 and the person chooses whose version to keep.
|
||||||
|
|
||||||
|
// stored: the server copy's lastEdited (null when there is none); base: what the client last saw
|
||||||
|
// (null/undefined when it never synced this study); force: the person chose "keep mine".
|
||||||
|
export function isSaveConflict({ stored, base, force = false }) {
|
||||||
|
if (force || stored == null) return false;
|
||||||
|
if (!Number.isFinite(base)) return false; // a device that never saw the server's copy has nothing to compare
|
||||||
|
return stored > base;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads the base header; anything that isn't a plain number is treated as "unknown".
|
||||||
|
export function parseBase(header) {
|
||||||
|
if (header == null || header === '') return null;
|
||||||
|
const n = Number(header);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { expect, test } from 'vitest';
|
||||||
|
import { isSaveConflict, parseBase } from './projectSync.js';
|
||||||
|
|
||||||
|
test('a save is fine when the server copy is no newer than what the device last saw', () => {
|
||||||
|
expect(isSaveConflict({ stored: 100, base: 100 })).toBe(false);
|
||||||
|
expect(isSaveConflict({ stored: 90, base: 100 })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a save conflicts when another device saved after the one this device last saw', () => {
|
||||||
|
expect(isSaveConflict({ stored: 200, base: 100 })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('first saves, unknown bases and "keep mine" never conflict', () => {
|
||||||
|
expect(isSaveConflict({ stored: null, base: 100 })).toBe(false);
|
||||||
|
expect(isSaveConflict({ stored: 200, base: null })).toBe(false);
|
||||||
|
expect(isSaveConflict({ stored: 200, base: 100, force: true })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reads the base header', () => {
|
||||||
|
expect(parseBase('1700000000000')).toBe(1700000000000);
|
||||||
|
expect(parseBase('')).toBeNull();
|
||||||
|
expect(parseBase(undefined)).toBeNull();
|
||||||
|
expect(parseBase('abc')).toBeNull();
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Validation for a user's synced Reader bookmarks and text highlights. The data comes
|
||||||
|
// from the browser, so everything is checked and malformed entries are dropped.
|
||||||
|
const COLOR = /^#[0-9a-fA-F]{3,8}$/;
|
||||||
|
const BOOKMARK_KEY = /^[1-3]?[A-Za-z]{2,3}-\d{1,3}-\d{1,3}$/; // e.g. "JHN-3-16", "1SA-3-5"
|
||||||
|
const BOOK = /^[1-3A-Za-z]{3}$/;
|
||||||
|
const MAX_BOOKMARKS = 5000;
|
||||||
|
const MAX_HIGHLIGHTS = 20000;
|
||||||
|
|
||||||
|
const int = (v, min, max) => Number.isInteger(v) && v >= min && v <= max;
|
||||||
|
|
||||||
|
// Returns { bookmarks, highlights } with only well-formed entries, or null when the body is
|
||||||
|
// not an object at all.
|
||||||
|
export function cleanMarks(body) {
|
||||||
|
if (!body || typeof body !== 'object' || Array.isArray(body)) return null;
|
||||||
|
|
||||||
|
const bookmarks = {};
|
||||||
|
if (body.bookmarks && typeof body.bookmarks === 'object' && !Array.isArray(body.bookmarks)) {
|
||||||
|
for (const [key, color] of Object.entries(body.bookmarks).slice(0, MAX_BOOKMARKS)) {
|
||||||
|
if (BOOKMARK_KEY.test(key) && typeof color === 'string' && COLOR.test(color)) bookmarks[key] = color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const highlights = [];
|
||||||
|
if (Array.isArray(body.highlights)) {
|
||||||
|
const seen = new Set();
|
||||||
|
for (const h of body.highlights.slice(0, MAX_HIGHLIGHTS)) {
|
||||||
|
if (!h || typeof h !== 'object') continue;
|
||||||
|
if (typeof h.id !== 'string' || h.id.length === 0 || h.id.length > 64 || seen.has(h.id)) continue;
|
||||||
|
if (!BOOK.test(String(h.book)) || !int(h.chapter, 1, 200) || !int(h.verse, 1, 200)) continue;
|
||||||
|
if (!int(h.startOffset, 0, 5000) || !int(h.endOffset, 1, 5000) || h.endOffset <= h.startOffset) continue;
|
||||||
|
if (typeof h.color !== 'string' || !COLOR.test(h.color)) continue;
|
||||||
|
seen.add(h.id);
|
||||||
|
const entry = {
|
||||||
|
id: h.id, book: String(h.book).toUpperCase(), chapter: h.chapter, verse: h.verse,
|
||||||
|
startOffset: h.startOffset, endOffset: h.endOffset, color: h.color,
|
||||||
|
};
|
||||||
|
// The highlighted words, kept so lists of highlights can show them without loading the chapter
|
||||||
|
if (typeof h.text === 'string' && h.text.length > 0) entry.text = h.text.slice(0, 500);
|
||||||
|
highlights.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { bookmarks, highlights };
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { mkdtempSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { test, expect } from 'vitest';
|
||||||
|
import { cleanMarks } from './readerMarks.js';
|
||||||
|
|
||||||
|
const hl = (over = {}) => ({ id: 'h1', book: 'JHN', chapter: 3, verse: 16, startOffset: 4, endOffset: 20, color: '#fef08a', ...over });
|
||||||
|
|
||||||
|
test('a non-object body is rejected', () => {
|
||||||
|
expect(cleanMarks(null)).toBeNull();
|
||||||
|
expect(cleanMarks([])).toBeNull();
|
||||||
|
expect(cleanMarks('x')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('good bookmarks and highlights pass through unchanged', () => {
|
||||||
|
const body = { bookmarks: { 'JHN-3-16': '#fef08a', '1SA-3-5': '#bbf7d0' }, highlights: [hl()] };
|
||||||
|
expect(cleanMarks(body)).toEqual(body);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('malformed bookmarks are dropped, good ones kept', () => {
|
||||||
|
const out = cleanMarks({ bookmarks: { 'JHN-3-16': '#fef08a', 'bad key': '#fef08a', 'GEN-1-1': 'red', 'GEN-1-2': 5, '../x-1-1': '#fff' } });
|
||||||
|
expect(out.bookmarks).toEqual({ 'JHN-3-16': '#fef08a' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('highlights are validated field by field and de-duplicated by id', () => {
|
||||||
|
const out = cleanMarks({ highlights: [
|
||||||
|
hl(),
|
||||||
|
hl({ id: 'h1', verse: 17 }), // duplicate id
|
||||||
|
hl({ id: '' }), hl({ id: 'x'.repeat(65) }),
|
||||||
|
hl({ id: 'a', book: 'JOHN' }), hl({ id: 'b', chapter: 0 }), hl({ id: 'c', verse: 201 }),
|
||||||
|
hl({ id: 'd', startOffset: 10, endOffset: 10 }), hl({ id: 'e', startOffset: -1 }), hl({ id: 'f', color: 'yellow' }),
|
||||||
|
'nope', null,
|
||||||
|
hl({ id: 'ok', book: 'gen' }),
|
||||||
|
] });
|
||||||
|
expect(out.highlights.map((h) => h.id)).toEqual(['h1', 'ok']);
|
||||||
|
expect(out.highlights[1].book).toBe('GEN'); // normalised
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing sections become empty ones, and unknown fields are not stored', () => {
|
||||||
|
expect(cleanMarks({})).toEqual({ bookmarks: {}, highlights: [] });
|
||||||
|
expect(cleanMarks({ highlights: [{ ...hl(), evil: '<script>' }] }).highlights[0]).not.toHaveProperty('evil');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the database keeps one row per user and updatedAt only moves forward', async () => {
|
||||||
|
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), 'marks-'));
|
||||||
|
const db = await import('./db.js');
|
||||||
|
db.initDb();
|
||||||
|
db.createUser({ id: 'u1', email: 'a@example.test', passwordHash: 'x' });
|
||||||
|
db.createUser({ id: 'u2', email: 'b@example.test', passwordHash: 'x' });
|
||||||
|
expect(db.getReaderMarks('u1')).toEqual({ bookmarks: {}, highlights: [], updatedAt: 0 });
|
||||||
|
const t1 = db.setReaderMarks('u1', { bookmarks: { 'JHN-3-16': '#fef08a' }, highlights: [] });
|
||||||
|
const t2 = db.setReaderMarks('u1', { bookmarks: {}, highlights: [hl()] });
|
||||||
|
expect(t2).toBeGreaterThan(t1);
|
||||||
|
const saved = db.getReaderMarks('u1');
|
||||||
|
expect(saved.updatedAt).toBe(t2);
|
||||||
|
expect(saved.bookmarks).toEqual({});
|
||||||
|
expect(saved.highlights).toHaveLength(1);
|
||||||
|
expect(db.getReaderMarks('u2').updatedAt).toBe(0); // other users are untouched
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Daily "don't forget to read" reminders, sent via ntfy.sh (https://ntfy.sh) —
|
||||||
|
// a free pub/sub push service: POST a message to a topic URL, anyone
|
||||||
|
// subscribed to that topic (via the ntfy app or a browser) gets notified.
|
||||||
|
// No API keys or push-subscription plumbing needed.
|
||||||
|
import { getUsersWithRemindersEnabled, setLastReminderDate, getReadingProgress } from './db.js';
|
||||||
|
|
||||||
|
// A reminder still goes out if the server was briefly down (or restarting) at the
|
||||||
|
// exact minute — as long as it's within this window after the chosen time.
|
||||||
|
const GRACE_MINUTES = 180;
|
||||||
|
const SEND_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
export function isValidTimeZone(tz) {
|
||||||
|
if (!tz) return false;
|
||||||
|
try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); return true; } catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The calendar date ("YYYY-MM-DD") and minutes-since-midnight in a timezone; the
|
||||||
|
// server's own timezone when none (or an invalid one) is saved.
|
||||||
|
export function zonedNow(date = new Date(), tz) {
|
||||||
|
const timeZone = isValidTimeZone(tz) ? tz : undefined;
|
||||||
|
const parts = Object.fromEntries(
|
||||||
|
new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
|
||||||
|
}).formatToParts(date).map((p) => [p.type, p.value]),
|
||||||
|
);
|
||||||
|
return { date: `${parts.year}-${parts.month}-${parts.day}`, minutes: Number(parts.hour) * 60 + Number(parts.minute) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is this user's reminder due right now (and not already sent today)?
|
||||||
|
export function isDue(user, now = new Date()) {
|
||||||
|
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(user.reminderTime ?? '');
|
||||||
|
if (!match) return false;
|
||||||
|
const target = Number(match[1]) * 60 + Number(match[2]);
|
||||||
|
const local = zonedNow(now, user.reminderTz);
|
||||||
|
if (user.lastReminderDate === local.date) return false;
|
||||||
|
return local.minutes >= target && local.minutes < target + GRACE_MINUTES;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POSTs one notification to the user's ntfy topic. Throws with a readable message on failure.
|
||||||
|
export async function sendNtfy(user, { title, body, tags = 'open_book' }) {
|
||||||
|
const base = (user.ntfyUrl || 'https://ntfy.sh').replace(/\/+$/, '');
|
||||||
|
const url = `${base}/${encodeURIComponent(user.ntfyTopic)}`;
|
||||||
|
const headers = { Title: title, Tags: tags };
|
||||||
|
if (process.env.APP_URL) headers.Click = process.env.APP_URL; // tapping the notification opens the app
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await fetch(url, { method: 'POST', headers, body, signal: AbortSignal.timeout(SEND_TIMEOUT_MS) });
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(err.name === 'TimeoutError' ? `Timed out reaching ${base}` : `Could not reach ${base} (${err.cause?.code ?? err.message})`);
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(`ntfy responded ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reminderBody(progress) {
|
||||||
|
const active = (Array.isArray(progress?.progress) ? progress.progress : []).filter((p) => !p.completedAt).length;
|
||||||
|
return active > 1 ? `Don't forget today's reading! You have ${active} plans in progress.` : "Don't forget today's reading!";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends a one-off notification so the user can confirm their topic/server work. */
|
||||||
|
export function sendTestReminder(user) {
|
||||||
|
return sendNtfy(user, { title: 'Reading reminders are working', body: 'This is a test from Bible Study Project. Your daily reminder will arrive like this.', tags: 'white_check_mark' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkReminders(now = new Date()) {
|
||||||
|
for (const user of getUsersWithRemindersEnabled().filter((u) => isDue(u, now))) {
|
||||||
|
try {
|
||||||
|
const progress = getReadingProgress(user.id);
|
||||||
|
const hasActivePlan = Array.isArray(progress?.progress) && progress.progress.some((p) => !p.completedAt);
|
||||||
|
if (!hasActivePlan) continue; // nothing to nudge them about
|
||||||
|
await sendNtfy(user, { title: 'Reading Plan Reminder', body: reminderBody(progress) });
|
||||||
|
setLastReminderDate(user.id, zonedNow(now, user.reminderTz).date);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Reading reminder failed for user ${user.id}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Checks once a minute whether any user's reminder time has arrived. */
|
||||||
|
export function startReminderScheduler() {
|
||||||
|
setInterval(() => {
|
||||||
|
checkReminders().catch((err) => console.error('Reminder scheduler error:', err));
|
||||||
|
}, 60 * 1000);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import http from 'node:http';
|
||||||
|
import { test, expect, afterEach } from 'vitest';
|
||||||
|
import { isDue, isValidTimeZone, reminderBody, sendNtfy, sendTestReminder, zonedNow } from './reminders.js';
|
||||||
|
|
||||||
|
const at = (iso) => new Date(iso);
|
||||||
|
|
||||||
|
test('zonedNow reads the date and time in the user\'s timezone, not the server\'s', () => {
|
||||||
|
// 2026-09-30T03:30Z is 23:30 the day before in New York, and 12:30 the same day in Tokyo
|
||||||
|
const d = at('2026-09-30T03:30:00Z');
|
||||||
|
expect(zonedNow(d, 'America/New_York')).toEqual({ date: '2026-09-29', minutes: 23 * 60 + 30 });
|
||||||
|
expect(zonedNow(d, 'Asia/Tokyo')).toEqual({ date: '2026-09-30', minutes: 12 * 60 + 30 });
|
||||||
|
expect(zonedNow(d, 'Not/AZone')).toEqual(zonedNow(d)); // bad timezone falls back to the server's
|
||||||
|
});
|
||||||
|
|
||||||
|
test('timezone validation', () => {
|
||||||
|
expect(isValidTimeZone('America/Chicago')).toBe(true);
|
||||||
|
expect(isValidTimeZone('Mars/Base')).toBe(false);
|
||||||
|
expect(isValidTimeZone('')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a reminder is due at its time in the user\'s timezone', () => {
|
||||||
|
const user = { reminderTime: '07:00', reminderTz: 'America/Chicago', lastReminderDate: null };
|
||||||
|
expect(isDue(user, at('2026-09-29T11:59:00Z'))).toBe(false); // 06:59 in Chicago (CDT)
|
||||||
|
expect(isDue(user, at('2026-09-29T12:00:00Z'))).toBe(true); // 07:00
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a late server still sends today\'s reminder, but only within the grace window', () => {
|
||||||
|
const user = { reminderTime: '07:00', reminderTz: 'America/Chicago', lastReminderDate: null };
|
||||||
|
expect(isDue(user, at('2026-09-29T12:40:00Z'))).toBe(true); // 07:40 — server was down at 07:00
|
||||||
|
expect(isDue(user, at('2026-09-29T15:30:00Z'))).toBe(false); // 10:30 — too late to be useful
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it is not sent twice on the same local day', () => {
|
||||||
|
const user = { reminderTime: '07:00', reminderTz: 'America/Chicago', lastReminderDate: '2026-09-29' };
|
||||||
|
expect(isDue(user, at('2026-09-29T12:05:00Z'))).toBe(false);
|
||||||
|
expect(isDue({ ...user, lastReminderDate: '2026-09-28' }, at('2026-09-29T12:05:00Z'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a missing or malformed time is never due', () => {
|
||||||
|
expect(isDue({ reminderTime: null }, new Date())).toBe(false);
|
||||||
|
expect(isDue({ reminderTime: '7am' }, new Date())).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the message mentions how many plans are in progress', () => {
|
||||||
|
expect(reminderBody({ progress: [{ completedAt: null }] })).toBe("Don't forget today's reading!");
|
||||||
|
expect(reminderBody({ progress: [{ completedAt: null }, { completedAt: null }, { completedAt: 1 }] })).toContain('2 plans');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- delivery, against a local stand-in for an ntfy server ------------------------
|
||||||
|
let server;
|
||||||
|
afterEach(() => new Promise((resolve) => (server ? server.close(resolve) : resolve())));
|
||||||
|
|
||||||
|
const startNtfy = (handler) => new Promise((resolve) => {
|
||||||
|
const seen = [];
|
||||||
|
server = http.createServer((req, res) => {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', (c) => { body += c; });
|
||||||
|
req.on('end', () => { seen.push({ method: req.method, url: req.url, headers: req.headers, body }); handler(res); });
|
||||||
|
});
|
||||||
|
server.listen(0, '127.0.0.1', () => resolve({ url: `http://127.0.0.1:${server.address().port}`, seen }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sendNtfy POSTs the message to <server>/<topic> with a title', async () => {
|
||||||
|
const ntfy = await startNtfy((res) => res.end('{}'));
|
||||||
|
await sendNtfy({ ntfyUrl: `${ntfy.url}/`, ntfyTopic: 'my-topic_1' }, { title: 'Hello', body: 'Body text' });
|
||||||
|
expect(ntfy.seen).toHaveLength(1);
|
||||||
|
expect(ntfy.seen[0]).toMatchObject({ method: 'POST', url: '/my-topic_1', body: 'Body text' });
|
||||||
|
expect(ntfy.seen[0].headers.title).toBe('Hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the test notification reaches the topic', async () => {
|
||||||
|
const ntfy = await startNtfy((res) => res.end('{}'));
|
||||||
|
await sendTestReminder({ ntfyUrl: ntfy.url, ntfyTopic: 'abc' });
|
||||||
|
expect(ntfy.seen[0].url).toBe('/abc');
|
||||||
|
expect(ntfy.seen[0].headers.title).toMatch(/working/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an ntfy error response becomes a readable failure', async () => {
|
||||||
|
const ntfy = await startNtfy((res) => { res.statusCode = 403; res.end('nope'); });
|
||||||
|
await expect(sendNtfy({ ntfyUrl: ntfy.url, ntfyTopic: 'abc' }, { title: 't', body: 'b' })).rejects.toThrow('ntfy responded 403');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unreachable server says so instead of hanging', async () => {
|
||||||
|
await expect(sendNtfy({ ntfyUrl: 'http://127.0.0.1:9', ntfyTopic: 'abc' }, { title: 't', body: 'b' })).rejects.toThrow(/Could not reach/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
// How the app moves and recovers: the Back button, the ⌘K palette, Undo, sync conflicts, backups.
|
||||||
|
import { render, screen, waitFor, within, fireEvent } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { vi, beforeEach, afterEach, describe, test, expect } from 'vitest';
|
||||||
|
import App from './App';
|
||||||
|
import { resetLexiconCaches } from './utils/wordStudy.js';
|
||||||
|
import { switchStorageUser } from './utils/bibleUtils.jsx';
|
||||||
|
import { resetSyncBases } from './utils/syncBase.js';
|
||||||
|
|
||||||
|
const chapterData = {
|
||||||
|
chapter: { content: [
|
||||||
|
{ type: 'verse', number: 1, content: ['In the beginning God created.'] },
|
||||||
|
{ type: 'verse', number: 2, content: ['The earth was without form.'] },
|
||||||
|
] },
|
||||||
|
};
|
||||||
|
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
|
||||||
|
|
||||||
|
// A fake server: signed in as `user`, a settable answer for saving studies, and a log of every call.
|
||||||
|
function stubServer({ user = null, putStatus = 200 } = {}) {
|
||||||
|
const calls = [];
|
||||||
|
const state = { putStatus, outlineToken: null };
|
||||||
|
vi.stubGlobal('fetch', vi.fn((url, opts = {}) => {
|
||||||
|
const route = `${opts.method ?? 'GET'} ${url.split('?')[0]}`;
|
||||||
|
calls.push({ route, url, opts });
|
||||||
|
if (url.includes('bible.helloao.org')) return json(200, chapterData);
|
||||||
|
if (!url.startsWith('/api/')) return Promise.resolve({ ok: false });
|
||||||
|
if (route === 'GET /api/auth/me') return user ? json(200, user) : json(401, { error: 'Not signed in' });
|
||||||
|
if (route === 'GET /api/projects') return json(200, []);
|
||||||
|
if (route === 'GET /api/reader/ink') return json(200, {});
|
||||||
|
if (route.endsWith('/outline-share')) {
|
||||||
|
if (opts.method === 'POST') state.outlineToken = 'abc123';
|
||||||
|
if (opts.method === 'DELETE') state.outlineToken = null;
|
||||||
|
return json(200, opts.method === 'DELETE' ? { ok: true } : { shareToken: state.outlineToken ?? null });
|
||||||
|
}
|
||||||
|
if (route === 'GET /api/share-outline/abc123') {
|
||||||
|
return json(200, { title: 'Titus', translation: 'BSB', outlines: [
|
||||||
|
{ scope: 'study', label: 'Whole study', tree: [{ id: 'a', text: 'Appoint elders', ref: '', kind: '', minutes: null, note: '', scripture: null, attachments: [], children: [] }] },
|
||||||
|
{ scope: 'c1', label: 'Titus 1:1–4', tree: [{ id: 'b', text: 'Greeting', ref: '', kind: '', minutes: null, note: '', scripture: null, attachments: [], children: [] }] },
|
||||||
|
] });
|
||||||
|
}
|
||||||
|
if (route === 'GET /api/share-outline/nope') return json(404, { error: 'This outline link is invalid or has been turned off.' });
|
||||||
|
if (route.startsWith('PUT /api/projects/')) {
|
||||||
|
return state.putStatus === 409
|
||||||
|
? json(409, { error: 'This study was changed on another device.', code: 'conflict', serverEdited: 999 })
|
||||||
|
: json(200, { id: url.split('/').pop().split('?')[0], title: 'x', lastEdited: 12345, chapterSummary: '' });
|
||||||
|
}
|
||||||
|
return json(404, { error: 'not mocked' });
|
||||||
|
}));
|
||||||
|
return { calls, state };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
resetLexiconCaches();
|
||||||
|
resetSyncBases();
|
||||||
|
switchStorageUser(null);
|
||||||
|
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ ok: false })));
|
||||||
|
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
|
||||||
|
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); });
|
||||||
|
|
||||||
|
async function renderApp() {
|
||||||
|
render(<App />);
|
||||||
|
await waitFor(() => expect(screen.queryByText('Loading…')).not.toBeInTheDocument());
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedProject({ id = 'p1', title = 'Titus Notes', observation = 'Grace abounds to all.' } = {}) {
|
||||||
|
const project = {
|
||||||
|
id, title, translation: 'BSB', lastEdited: Date.now(), selectedChunkId: null,
|
||||||
|
chapters: [{
|
||||||
|
book: 'Titus', bookAbbrev: 'TIT', chapter: '1', verses: [{ number: 1, text: 'Paul, a servant of God.' }],
|
||||||
|
chunks: [{ id: 'c1', startVerse: 1, endVerse: 1, observation, interpretation: '', application: '', crossReferences: [], greekWords: [], tags: [] }],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
const index = JSON.parse(localStorage.getItem('bible-study-index') || '[]');
|
||||||
|
index.push({ id, title, lastEdited: project.lastEdited, chapterSummary: 'Titus 1', tags: [] });
|
||||||
|
localStorage.setItem(`bible-study-project-${id}`, JSON.stringify(project));
|
||||||
|
localStorage.setItem('bible-study-index', JSON.stringify(index));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Back button', () => {
|
||||||
|
test('moves back through the pages you visited instead of leaving the app', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
expect(await screen.findByRole('heading', { name: 'My Studies' })).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: /reading plans/i }));
|
||||||
|
expect(await screen.findByRole('heading', { name: /reading plans/i })).toBeInTheDocument();
|
||||||
|
window.history.back();
|
||||||
|
expect(await screen.findByRole('heading', { name: 'My Studies' })).toBeInTheDocument();
|
||||||
|
window.history.forward();
|
||||||
|
expect(await screen.findByRole('heading', { name: /reading plans/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const paletteBox = async () => within(await screen.findByRole('dialog', { name: /search and go to/i })).getByRole('combobox');
|
||||||
|
|
||||||
|
describe('Search palette (⌘K)', () => {
|
||||||
|
test('a typed passage goes straight to the Bible reader', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await user.keyboard('{Control>}k{/Control}');
|
||||||
|
await user.type(await paletteBox(), 'john 3:16');
|
||||||
|
expect(await screen.findByRole('option', { name: /John 3:16/ })).toBeInTheDocument();
|
||||||
|
await user.keyboard('{Enter}');
|
||||||
|
expect(await screen.findByRole('heading', { name: /John 3/ })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('dialog', { name: /search and go to/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finds a note you wrote and opens that study', async () => {
|
||||||
|
seedProject({ observation: 'Grace abounds to all.' });
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await user.keyboard('{Control>}k{/Control}');
|
||||||
|
await user.type(await paletteBox(), 'abounds');
|
||||||
|
const hit = await screen.findByRole('option', { name: /Titus 1:1/ });
|
||||||
|
await user.click(hit);
|
||||||
|
expect(await screen.findByText(/Your notes/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Escape closes it', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await user.keyboard('{Control>}k{/Control}');
|
||||||
|
await paletteBox();
|
||||||
|
await user.keyboard('{Escape}');
|
||||||
|
expect(screen.queryByRole('dialog', { name: /search and go to/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('? lists the keyboard shortcuts', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await user.keyboard('?');
|
||||||
|
expect(await screen.findByRole('dialog', { name: /keyboard shortcuts/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Undo', () => {
|
||||||
|
test('deleting a project, then Undo, keeps its notes', async () => {
|
||||||
|
seedProject();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await user.click(screen.getByRole('button', { name: /^delete$/i }));
|
||||||
|
expect(await screen.findByRole('status')).toHaveTextContent(/deleted/i);
|
||||||
|
await user.click(screen.getByRole('button', { name: /undo/i }));
|
||||||
|
expect(await screen.findByText('Titus Notes')).toBeInTheDocument();
|
||||||
|
expect(JSON.parse(localStorage.getItem('bible-study-project-p1')).chapters[0].chunks[0].observation).toBe('Grace abounds to all.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Sync conflict', () => {
|
||||||
|
const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: '', isAdmin: false };
|
||||||
|
|
||||||
|
async function startStudy(u) {
|
||||||
|
await renderApp();
|
||||||
|
await u.click((await screen.findAllByRole('button', { name: /new project/i }))[0]);
|
||||||
|
await u.click(await screen.findByRole('button', { name: /load chapter/i }));
|
||||||
|
await screen.findByText(/Scripture & Chunks/i);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a save the server refuses shows the choice instead of overwriting', async () => {
|
||||||
|
const server = stubServer({ user, putStatus: 409 });
|
||||||
|
const u = userEvent.setup();
|
||||||
|
await startStudy(u);
|
||||||
|
const banner = await screen.findByRole('alert', {}, { timeout: 4000 });
|
||||||
|
expect(banner).toHaveTextContent(/changed on another device/i);
|
||||||
|
expect(within(banner).getByRole('button', { name: /keep this version/i })).toBeInTheDocument();
|
||||||
|
expect(within(banner).getByRole('button', { name: /use the other version/i })).toBeInTheDocument();
|
||||||
|
|
||||||
|
// "Keep this version" sends the save again, this time as an overwrite
|
||||||
|
server.state.putStatus = 200;
|
||||||
|
await u.click(within(banner).getByRole('button', { name: /keep this version/i }));
|
||||||
|
await waitFor(() => expect(server.calls.some((c) => c.url.includes('?force=1'))).toBe(true));
|
||||||
|
await waitFor(() => expect(screen.queryByText(/changed on another device/i)).not.toBeInTheDocument());
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
test('opening a study without editing it does not touch the server', async () => {
|
||||||
|
seedProject();
|
||||||
|
const server = stubServer({ user });
|
||||||
|
const u = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
// signed in: the namespaced storage starts empty, so seed under the user's keys by resuming nothing
|
||||||
|
expect(server.calls.filter((c) => c.route.startsWith('PUT')).length).toBe(0);
|
||||||
|
await u.click(await screen.findByRole('button', { name: /read bible/i }));
|
||||||
|
expect(server.calls.filter((c) => c.route.startsWith('PUT /api/projects')).length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Outline stage', () => {
|
||||||
|
async function openOutline() {
|
||||||
|
// local-only mode (the account server is unreachable), with chapter text available
|
||||||
|
vi.stubGlobal('fetch', vi.fn((url) => (url.includes('bible.helloao.org') ? json(200, chapterData) : Promise.resolve({ ok: false }))));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await user.click((await screen.findAllByRole('button', { name: /new project/i }))[0]);
|
||||||
|
await user.click(await screen.findByRole('button', { name: /load chapter/i }));
|
||||||
|
await screen.findByText(/Scripture & Chunks/i);
|
||||||
|
// one chunk covering verses 1-2
|
||||||
|
const verses = within(screen.getByTestId('verse-list'));
|
||||||
|
fireEvent.click(verses.getByRole('button', { name: /In the beginning/ }));
|
||||||
|
fireEvent.click(verses.getByRole('button', { name: /without form/ }), { shiftKey: true });
|
||||||
|
await screen.findByText('1 created');
|
||||||
|
await user.click(screen.getByRole('button', { name: /^outline$/i }));
|
||||||
|
await screen.findByRole('heading', { name: 'Outline' });
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('starts empty, and "Start from my chunks" makes a main point for each chunk', async () => {
|
||||||
|
const user = await openOutline();
|
||||||
|
expect(screen.getByText(/no outline yet/i)).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: /start from my chunks/i }));
|
||||||
|
const list = await screen.findByRole('list', { name: 'Outline' });
|
||||||
|
expect(within(list).getAllByRole('textbox')).toHaveLength(1);
|
||||||
|
expect(within(list).getByText(/Open chunk in Study/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Enter adds a point, Tab makes it a sub-point, and they are numbered I. and A.', async () => {
|
||||||
|
const user = await openOutline();
|
||||||
|
await user.click(screen.getByRole('button', { name: /start blank/i }));
|
||||||
|
const first = await screen.findByRole('textbox', { name: 'Point I.' });
|
||||||
|
await user.type(first, 'Creation{Enter}');
|
||||||
|
const second = await screen.findByRole('textbox', { name: 'Point II.' });
|
||||||
|
await user.type(second, 'Light{Tab}');
|
||||||
|
expect(await screen.findByRole('textbox', { name: 'Point A.' })).toHaveValue('Light');
|
||||||
|
expect(screen.getByRole('textbox', { name: 'Point I.' })).toHaveValue('Creation');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleting a point can be undone', async () => {
|
||||||
|
const user = await openOutline();
|
||||||
|
await user.click(screen.getByRole('button', { name: /start blank/i }));
|
||||||
|
await user.type(await screen.findByRole('textbox', { name: 'Point I.' }), 'Keep me');
|
||||||
|
await user.click(screen.getByRole('button', { name: /delete i\./i }));
|
||||||
|
expect(screen.queryByRole('textbox', { name: 'Point I.' })).not.toBeInTheDocument();
|
||||||
|
await user.click(await screen.findByRole('button', { name: /undo/i }));
|
||||||
|
expect(await screen.findByRole('textbox', { name: 'Point I.' })).toHaveValue('Keep me');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Outline: per chunk, details, print and share', () => {
|
||||||
|
async function openOutline() {
|
||||||
|
vi.stubGlobal('fetch', vi.fn((url) => (url.includes('bible.helloao.org') ? json(200, chapterData) : Promise.resolve({ ok: false }))));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await user.click((await screen.findAllByRole('button', { name: /new project/i }))[0]);
|
||||||
|
await user.click(await screen.findByRole('button', { name: /load chapter/i }));
|
||||||
|
await screen.findByText(/Scripture & Chunks/i);
|
||||||
|
const verses = within(screen.getByTestId('verse-list'));
|
||||||
|
fireEvent.click(verses.getByRole('button', { name: /In the beginning/ }));
|
||||||
|
fireEvent.click(verses.getByRole('button', { name: /without form/ }), { shiftKey: true });
|
||||||
|
await screen.findByText('1 created');
|
||||||
|
await user.click(screen.getByRole('button', { name: /^outline$/i }));
|
||||||
|
await screen.findByRole('heading', { name: 'Outline' });
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('each chunk has its own outline, separate from the whole study\'s', async () => {
|
||||||
|
const user = await openOutline();
|
||||||
|
const picker = screen.getByRole('combobox', { name: 'Outline for' });
|
||||||
|
await user.selectOptions(picker, picker.querySelectorAll('option')[1]); // the chunk
|
||||||
|
expect(await screen.findByText(/no outline yet for/i)).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: /start from the verses/i }));
|
||||||
|
const list = await screen.findByRole('list', { name: 'Outline' });
|
||||||
|
expect(within(list).getAllByRole('textbox')).toHaveLength(2); // one point per verse
|
||||||
|
expect(within(list).getByText(/In the beginning God created/)).toBeInTheDocument(); // with its words
|
||||||
|
await user.selectOptions(screen.getByRole('combobox', { name: 'Outline for' }), 'study');
|
||||||
|
expect(await screen.findByText(/^No outline yet$/)).toBeInTheDocument(); // the whole study is untouched
|
||||||
|
expect(screen.getByRole('option', { name: /· 2/ })).toBeInTheDocument(); // the chunk shows its count
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a point can be given a type and minutes, and the total shows', async () => {
|
||||||
|
const user = await openOutline();
|
||||||
|
await user.click(screen.getByRole('button', { name: /start blank/i }));
|
||||||
|
await user.type(await screen.findByRole('textbox', { name: 'Point I.' }), 'The story');
|
||||||
|
await user.click(screen.getByRole('button', { name: /details for i\./i }));
|
||||||
|
await user.selectOptions(screen.getByLabelText('Type'), 'illustration');
|
||||||
|
await user.type(screen.getByLabelText('Minutes'), '5');
|
||||||
|
expect(await screen.findByText('About 5 minutes')).toBeInTheDocument();
|
||||||
|
await user.type(screen.getByRole('textbox', { name: 'Notes for I.' }), 'Tell it slowly');
|
||||||
|
await user.click(screen.getByRole('button', { name: /details for i\./i })); // close: the note shows as a line
|
||||||
|
expect(await screen.findByText('Tell it slowly')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Illustration')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('"Add verse text" puts the passage\'s words into the point', async () => {
|
||||||
|
const user = await openOutline();
|
||||||
|
await user.click(screen.getByRole('button', { name: /start blank/i }));
|
||||||
|
await user.click(await screen.findByRole('button', { name: /details for i\./i }));
|
||||||
|
await user.type(screen.getByLabelText('Passage'), 'Titus 1:1');
|
||||||
|
await user.click(screen.getByRole('button', { name: /add verse text/i }));
|
||||||
|
expect(await screen.findByText(/In the beginning God created/)).toBeInTheDocument();
|
||||||
|
await user.clear(screen.getByLabelText('Passage'));
|
||||||
|
await user.type(screen.getByLabelText('Passage'), 'nonsense');
|
||||||
|
await user.click(screen.getByRole('button', { name: /replace verse text/i }));
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent(/type a passage/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Share opens a sheet; with no account it offers the text, which is copied', async () => {
|
||||||
|
const user = await openOutline();
|
||||||
|
await user.click(screen.getByRole('button', { name: /start from my chunks/i }));
|
||||||
|
await screen.findByRole('list', { name: 'Outline' });
|
||||||
|
await user.click(screen.getByRole('button', { name: /^share$/i }));
|
||||||
|
const sheet = await screen.findByRole('dialog', { name: /share this outline/i });
|
||||||
|
expect(await within(sheet).findByText(/links need your account/i)).toBeInTheDocument();
|
||||||
|
await user.click(within(sheet).getByRole('button', { name: /share as text/i }));
|
||||||
|
expect(await screen.findByText(/outline copied/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Print opens a page of just the outline', async () => {
|
||||||
|
const user = await openOutline();
|
||||||
|
await user.click(screen.getByRole('button', { name: /start from my chunks/i }));
|
||||||
|
await screen.findByRole('list', { name: 'Outline' });
|
||||||
|
const doc = { write: vi.fn(), close: vi.fn() };
|
||||||
|
vi.spyOn(window, 'open').mockReturnValue({ document: doc, focus: vi.fn(), print: vi.fn() });
|
||||||
|
await user.click(screen.getByRole('button', { name: /print \/ pdf/i }));
|
||||||
|
expect(doc.write.mock.calls[0][0]).toContain('class="outline"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Read-only outline link', () => {
|
||||||
|
const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: '', isAdmin: false };
|
||||||
|
|
||||||
|
test('create the link, see it, and turn it off', async () => {
|
||||||
|
const server = stubServer({ user });
|
||||||
|
const u = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await u.click((await screen.findAllByRole('button', { name: /new project/i }))[0]);
|
||||||
|
await u.click(await screen.findByRole('button', { name: /load chapter/i }));
|
||||||
|
await screen.findByText(/Scripture & Chunks/i);
|
||||||
|
const verses = within(screen.getByTestId('verse-list'));
|
||||||
|
fireEvent.click(verses.getByRole('button', { name: /In the beginning/ }));
|
||||||
|
fireEvent.click(verses.getByRole('button', { name: /without form/ }), { shiftKey: true });
|
||||||
|
await screen.findByText('1 created');
|
||||||
|
await u.click(screen.getByRole('button', { name: /^outline$/i }));
|
||||||
|
await u.click(await screen.findByRole('button', { name: /start from my chunks/i }));
|
||||||
|
await u.click(await screen.findByRole('button', { name: /^share$/i }));
|
||||||
|
const sheet = await screen.findByRole('dialog', { name: /share this outline/i });
|
||||||
|
await u.click(await within(sheet).findByRole('button', { name: /create read-only link/i }));
|
||||||
|
const box = await within(sheet).findByLabelText('Outline link');
|
||||||
|
expect(box.value).toMatch(/\?outline=abc123$/);
|
||||||
|
expect(server.calls.some((c) => c.route === 'POST /api/projects/' + c.url.split('/')[3] + '/outline-share')).toBe(true);
|
||||||
|
await u.click(within(sheet).getByRole('button', { name: /turn off link/i }));
|
||||||
|
expect(await within(sheet).findByRole('button', { name: /create read-only link/i })).toBeInTheDocument();
|
||||||
|
}, 15000);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Opening a read-only outline link', () => {
|
||||||
|
afterEach(() => window.history.replaceState({}, '', '/'));
|
||||||
|
|
||||||
|
test('shows the outline with no sign-in and a way to switch between outlines', async () => {
|
||||||
|
stubServer({ user: null });
|
||||||
|
window.history.replaceState({}, '', '/?outline=abc123');
|
||||||
|
render(<App />);
|
||||||
|
expect(await screen.findByRole('heading', { name: 'Titus' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Appoint elders')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: /sign in/i })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); // nothing to edit
|
||||||
|
await userEvent.setup().click(screen.getByRole('button', { name: 'Titus 1:1–4' }));
|
||||||
|
expect(await screen.findByRole('heading', { name: 'Titus — Titus 1:1–4' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Greeting')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Appoint elders')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a turned-off or wrong link says so', async () => {
|
||||||
|
stubServer({ user: null });
|
||||||
|
window.history.replaceState({}, '', '/?outline=nope');
|
||||||
|
render(<App />);
|
||||||
|
expect(await screen.findByText(/invalid or has been turned off/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Study cards on Home', () => {
|
||||||
|
test('a long run of chapters and many tags stay short', async () => {
|
||||||
|
const chapters = Array.from({ length: 24 }, (_, i) => `Luke ${i + 1}`).join(', ');
|
||||||
|
const tags = ['A', 'B', 'C', 'D', 'E', 'F'];
|
||||||
|
localStorage.setItem('bible-study-index', JSON.stringify([{ id: 'p', title: 'Luke', lastEdited: Date.now(), chapterSummary: chapters, tags }]));
|
||||||
|
await renderApp();
|
||||||
|
expect(await screen.findByText('Luke 1\u201324')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('+3 more')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('F', { selector: 'span' })).not.toBeInTheDocument(); // only in the Tag filter
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Window title', () => {
|
||||||
|
test('names the page you are on, including the chapter in the reader', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
expect(document.title).toBe('My Studies · Bible Study');
|
||||||
|
await user.click(screen.getByRole('button', { name: /reading plans/i }));
|
||||||
|
await waitFor(() => expect(document.title).toBe('Reading Plans · Bible Study'));
|
||||||
|
await user.click(screen.getByRole('button', { name: /read bible/i }));
|
||||||
|
await waitFor(() => expect(document.title).toMatch(/^[A-Za-z0-9 ]+ \d+ · Bible Study$/));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,15 +1,26 @@
|
|||||||
import { lazy, Suspense, useEffect, useRef, useState } from 'react';
|
import { lazy, Suspense, useEffect, useRef, useState } from 'react';
|
||||||
|
import { getPlanSchedule, nearestReadingDay } from './utils/readingPlans.js';
|
||||||
|
import { applySystemChrome } from './utils/systemChrome.js';
|
||||||
import { AppContext, useApp } from './context/AppContext.js';
|
import { AppContext, useApp } from './context/AppContext.js';
|
||||||
|
import { ToastProvider, useToast } from './components/Toasts.jsx';
|
||||||
|
import CommandPalette from './components/CommandPalette.jsx';
|
||||||
|
import ShortcutsHelp from './components/ShortcutsHelp.jsx';
|
||||||
|
import SharedOutlineView from './components/SharedOutlineView.jsx';
|
||||||
import { useAudio } from './hooks/useAudio.js';
|
import { useAudio } from './hooks/useAudio.js';
|
||||||
import { useAuth } from './hooks/useAuth.js';
|
import { useAuth } from './hooks/useAuth.js';
|
||||||
import { useBibleReader } from './hooks/useBibleReader.js';
|
import { useBibleReader } from './hooks/useBibleReader.js';
|
||||||
import { useProjectLibrary } from './hooks/useProjectLibrary.js';
|
import { useProjectLibrary } from './hooks/useProjectLibrary.js';
|
||||||
import { useReadingPlan } from './hooks/useReadingPlan.js';
|
import { useHistoryNav } from './hooks/useHistoryNav.js';
|
||||||
|
import { useReadingPlans } from './hooks/useReadingPlans.js';
|
||||||
import { useSharedView } from './hooks/useSharedView.js';
|
import { useSharedView } from './hooks/useSharedView.js';
|
||||||
import HomePage from './pages/HomePage.jsx';
|
import HomePage from './pages/HomePage.jsx';
|
||||||
import { saveRemoteProject } from './syncService.js';
|
import { themeVars } from './components/reader/readerThemes.js';
|
||||||
import { buildExportHtml, loadProjectIndex, switchStorageUser } from './utils/bibleUtils.jsx';
|
import { loadRemoteProject, saveRemoteProject } from './syncService.js';
|
||||||
import { migrateOldStorageKeys, saveProjectToStorage } from './utils/projectStorage.js';
|
import { getSyncBase, setSyncBase } from './utils/syncBase.js';
|
||||||
|
import {
|
||||||
|
bookOptions, buildExportHtml, loadProjectIndex, makeId, parseBibleChapter, switchStorageUser,
|
||||||
|
} from './utils/bibleUtils.jsx';
|
||||||
|
import { loadProjectById, migrateOldStorageKeys, saveProjectToStorage } from './utils/projectStorage.js';
|
||||||
|
|
||||||
// Less-common pages are split into their own chunks so the first load only
|
// Less-common pages are split into their own chunks so the first load only
|
||||||
// downloads what's needed to show the home screen.
|
// downloads what's needed to show the home screen.
|
||||||
@@ -20,23 +31,54 @@ const ReaderPage = lazy(() => import('./pages/ReaderPage.jsx'));
|
|||||||
const SettingsPage = lazy(() => import('./pages/SettingsPage.jsx'));
|
const SettingsPage = lazy(() => import('./pages/SettingsPage.jsx'));
|
||||||
const SetupPage = lazy(() => import('./pages/SetupPage.jsx'));
|
const SetupPage = lazy(() => import('./pages/SetupPage.jsx'));
|
||||||
const StudyPage = lazy(() => import('./pages/StudyPage.jsx'));
|
const StudyPage = lazy(() => import('./pages/StudyPage.jsx'));
|
||||||
|
const OutlinePage = lazy(() => import('./pages/OutlinePage.jsx'));
|
||||||
|
const ScriptPage = lazy(() => import('./pages/ScriptPage.jsx'));
|
||||||
|
const ExportPage = lazy(() => import('./pages/ExportPage.jsx'));
|
||||||
const PronunciationPage = lazy(() => import('./pages/PronunciationPage.jsx'));
|
const PronunciationPage = lazy(() => import('./pages/PronunciationPage.jsx'));
|
||||||
|
const ReadingPlansPage = lazy(() => import('./pages/ReadingPlansPage.jsx'));
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// App component
|
// App component
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const App = () => {
|
// The page each "Home page" choice in Settings opens.
|
||||||
|
const START_PAGES = { studies: 'home', reader: 'reader', plans: 'reading-plans' };
|
||||||
|
|
||||||
|
// The user's chosen home page, read straight from this device's saved preference so the app
|
||||||
|
// starts on it from the very first render (no flash of another page first).
|
||||||
|
function savedStartPage() {
|
||||||
|
try { return START_PAGES[localStorage.getItem('start-page')] ?? 'home'; } catch { return 'home'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const AppInner = () => {
|
||||||
|
const { toast, setTheme: setToastTheme } = useToast();
|
||||||
const [project, setProject] = useState(null);
|
const [project, setProject] = useState(null);
|
||||||
// 'home' | 'setup' | 'study' | 'reader' | 'import' | 'pronunciation' | 'settings' | 'admin'
|
// 'home' | 'setup' | 'study' | 'outline' | 'script' | 'export' | 'reader' | 'import' | 'pronunciation' | 'settings' | 'admin' | 'reading-plans'
|
||||||
const [currentPage, setCurrentPage] = useState('home');
|
const [currentPage, setCurrentPage] = useState(savedStartPage);
|
||||||
|
const currentPageRef = useRef(currentPage);
|
||||||
|
currentPageRef.current = currentPage;
|
||||||
const [projectIndex, setProjectIndex] = useState([]);
|
const [projectIndex, setProjectIndex] = useState([]);
|
||||||
const [saveStatus, setSaveStatus] = useState('');
|
const [saveStatus, setSaveStatus] = useState('');
|
||||||
const saveTimerRef = useRef(null);
|
const saveTimerRef = useRef(null);
|
||||||
const [syncStatus, setSyncStatus] = useState(''); // '' | 'syncing' | 'synced' | 'error'
|
const [syncStatus, setSyncStatus] = useState(''); // '' | 'syncing' | 'synced' | 'error'
|
||||||
const reader = useBibleReader(currentPage);
|
const [studyBuildBusy, setStudyBuildBusy] = useState(false);
|
||||||
const audio = useAudio(reader.readerBookAbbrev, reader.readerChapter);
|
const readingPlans = useReadingPlans();
|
||||||
const readingPlan = useReadingPlan();
|
// The plan day currently open in the Reader ({ progressId, dayIndex }) — set when
|
||||||
|
// you read from a plan, cleared when you leave the Reader. While set, the Reader
|
||||||
|
// uses that plan's own translation.
|
||||||
|
const [readerPlan, setReaderPlan] = useState(null);
|
||||||
|
const readerPlanProgress = readerPlan ? readingPlans.planProgress.find((p) => p.id === readerPlan.progressId) ?? null : null;
|
||||||
|
const reader = useBibleReader(currentPage, readerPlanProgress?.translation ?? null);
|
||||||
|
const audio = useAudio(reader.readerBookAbbrev, reader.readerChapter, reader.readerTranslation);
|
||||||
|
useEffect(() => { if (currentPage !== 'reader') setReaderPlan(null); }, [currentPage]);
|
||||||
|
// A new page starts at the top. Without this the scroll position of the previous page carries over, and on
|
||||||
|
// iPad content scrolled under the top edge makes the system blur the app bar.
|
||||||
|
useEffect(() => {
|
||||||
|
const scroller = document.scrollingElement ?? document.documentElement;
|
||||||
|
if (scroller) scroller.scrollTop = 0;
|
||||||
|
}, [currentPage]);
|
||||||
|
// Keep the browser/OS chrome (status bar area, overscroll, scrollbars) in the chosen theme
|
||||||
|
useEffect(() => { applySystemChrome(reader.readerTheme); setToastTheme(reader.readerTheme); }, [reader.readerTheme, setToastTheme]);
|
||||||
const { authUser, setAuthUser, authServerDown, signIn, logout } = useAuth({
|
const { authUser, setAuthUser, authServerDown, signIn, logout } = useAuth({
|
||||||
onSignIn: (user) => {
|
onSignIn: (user) => {
|
||||||
setProjectIndex(switchStorageUser(user.id));
|
setProjectIndex(switchStorageUser(user.id));
|
||||||
@@ -45,10 +87,20 @@ const App = () => {
|
|||||||
.then((r) => r.ok ? r.json() : null)
|
.then((r) => r.ok ? r.json() : null)
|
||||||
.then((serverInk) => { if (serverInk) reader.mergeServerInk(serverInk); })
|
.then((serverInk) => { if (serverInk) reader.mergeServerInk(serverInk); })
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
// Same for reading plan progress — pullServerReadingProgress surfaces
|
||||||
|
// a failed GET via the same error banner a failed push uses, instead
|
||||||
|
// of silently leaving stale/empty local state in place.
|
||||||
|
readingPlans.pullServerReadingProgress();
|
||||||
|
// Bookmarks and highlights: reconcile this device's copy with the account's.
|
||||||
|
reader.pullServerMarks();
|
||||||
|
// Theme, default translation, layout, commentary and reading position.
|
||||||
|
reader.pullServerPrefs();
|
||||||
},
|
},
|
||||||
onSignOut: () => {
|
onSignOut: () => {
|
||||||
|
reader.stopMarksSync();
|
||||||
|
reader.stopPrefsSync();
|
||||||
setProjectIndex(switchStorageUser(null));
|
setProjectIndex(switchStorageUser(null));
|
||||||
goHome();
|
goStudies();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const library = useProjectLibrary({
|
const library = useProjectLibrary({
|
||||||
@@ -59,6 +111,49 @@ const App = () => {
|
|||||||
setCurrentPage,
|
setCurrentPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ⌘K search/go-to, the "?" shortcut list, and requests other pages make of the Reader.
|
||||||
|
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||||
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
|
const [readerRequest, setReaderRequest] = useState(null); // { search: 'text' } from the palette
|
||||||
|
const [settingsFocus, setSettingsFocus] = useState(null); // section Settings should scroll to
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && !e.altKey && e.key.toLowerCase() === 'k') { e.preventDefault(); setHelpOpen(false); setPaletteOpen((o) => !o); return; }
|
||||||
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
|
const t = e.target;
|
||||||
|
if (t?.tagName === 'INPUT' || t?.tagName === 'TEXTAREA' || t?.tagName === 'SELECT' || t?.isContentEditable) return;
|
||||||
|
if (e.key === '?') { e.preventDefault(); setPaletteOpen(false); setHelpOpen((o) => !o); }
|
||||||
|
else if (e.key === '/' && currentPageRef.current !== 'reader') { e.preventDefault(); setPaletteOpen(true); }
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// The browser tab and history show where you are ("Romans 8 · Bible Study", "Luke — Outline · Bible Study")
|
||||||
|
useEffect(() => {
|
||||||
|
const names = {
|
||||||
|
home: 'My Studies', setup: 'Chunks', study: 'Study', outline: 'Outline', script: 'Script', export: 'Export',
|
||||||
|
import: 'Import', pronunciation: 'Pronunciation Guide', settings: 'Settings', admin: 'Admin', 'reading-plans': 'Reading Plans',
|
||||||
|
};
|
||||||
|
let page = names[currentPage] ?? '';
|
||||||
|
if (currentPage === 'reader') {
|
||||||
|
const book = bookOptions.find((b) => b.abbrev === reader.readerBookAbbrev)?.name ?? '';
|
||||||
|
page = `${book} ${reader.readerChapter}`.trim();
|
||||||
|
} else if (project?.title && ['setup', 'study', 'outline', 'script', 'export', 'pronunciation'].includes(currentPage)) {
|
||||||
|
page = `${project.title} — ${page}`;
|
||||||
|
}
|
||||||
|
document.title = page ? `${page} · Bible Study` : 'Bible Study';
|
||||||
|
}, [currentPage, project?.title, reader.readerBookAbbrev, reader.readerChapter]);
|
||||||
|
|
||||||
|
// Back/Forward move between pages instead of leaving the app. (Not for ?share= links: that page is separate.)
|
||||||
|
useHistoryNav({
|
||||||
|
page: currentPage,
|
||||||
|
setPage: setCurrentPage,
|
||||||
|
hasProject: !!project,
|
||||||
|
enabled: !new URLSearchParams(window.location.search).has('share') && !new URLSearchParams(window.location.search).has('outline'),
|
||||||
|
onArrive: (page) => { if (page === 'home') setProjectIndex(loadProjectIndex()); },
|
||||||
|
});
|
||||||
|
|
||||||
// Startup: migrate old keys and load index
|
// Startup: migrate old keys and load index
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
migrateOldStorageKeys();
|
migrateOldStorageKeys();
|
||||||
@@ -68,10 +163,45 @@ const App = () => {
|
|||||||
const allChunks = project ? project.chapters.flatMap((ch) => ch.chunks) : [];
|
const allChunks = project ? project.chapters.flatMap((ch) => ch.chunks) : [];
|
||||||
|
|
||||||
// Autosave: 1s after the last edit, save locally, then sync to the server.
|
// Autosave: 1s after the last edit, save locally, then sync to the server.
|
||||||
|
// Opening a study doesn't count as editing it — saving then would stamp the server's copy as "newer"
|
||||||
|
// and make your other devices think it changed.
|
||||||
|
const savedSignatureRef = useRef({ id: null, signature: '' });
|
||||||
|
const [syncConflict, setSyncConflict] = useState(null); // { id, serverEdited } when the server has a newer copy
|
||||||
|
const conflictRef = useRef(null);
|
||||||
|
conflictRef.current = syncConflict;
|
||||||
|
const signatureOf = (p) => JSON.stringify({ ...p, lastEdited: 0 });
|
||||||
|
|
||||||
|
// Sends a study to the server, checked against the copy this device last saw.
|
||||||
|
const pushProject = async (toSave, { force = false } = {}) => {
|
||||||
|
setSyncStatus('syncing');
|
||||||
|
const result = await saveRemoteProject(toSave, { baseEdited: getSyncBase(toSave.id), force });
|
||||||
|
if (result.ok) {
|
||||||
|
setSyncBase(toSave.id, result.data?.lastEdited ?? toSave.lastEdited);
|
||||||
|
setSyncConflict((c) => (c?.id === toSave.id ? null : c));
|
||||||
|
setSyncStatus('synced');
|
||||||
|
window.setTimeout(() => setSyncStatus(''), 2500);
|
||||||
|
} else if (result.status === 409) {
|
||||||
|
setSyncConflict({ id: toSave.id, serverEdited: result.data?.serverEdited ?? null });
|
||||||
|
setSyncStatus('conflict');
|
||||||
|
} else {
|
||||||
|
setSyncStatus('error'); // kept visible until the next successful sync
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!project) return;
|
if (!project) return undefined;
|
||||||
|
const signature = signatureOf(project);
|
||||||
|
const last = savedSignatureRef.current;
|
||||||
|
if (last.id !== project.id) {
|
||||||
|
savedSignatureRef.current = { id: project.id, signature };
|
||||||
|
if (loadProjectById(project.id)) return undefined; // just opened an existing study: nothing to save yet
|
||||||
|
} else if (last.signature === signature) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
||||||
saveTimerRef.current = window.setTimeout(async () => {
|
saveTimerRef.current = window.setTimeout(async () => {
|
||||||
|
savedSignatureRef.current = { id: project.id, signature };
|
||||||
// Stamp lastEdited once so localStorage and server receive the same value
|
// Stamp lastEdited once so localStorage and server receive the same value
|
||||||
const toSave = { ...project, lastEdited: Date.now() };
|
const toSave = { ...project, lastEdited: Date.now() };
|
||||||
|
|
||||||
@@ -81,17 +211,34 @@ const App = () => {
|
|||||||
setSaveStatus('Saved');
|
setSaveStatus('Saved');
|
||||||
window.setTimeout(() => setSaveStatus(''), 1400);
|
window.setTimeout(() => setSaveStatus(''), 1400);
|
||||||
|
|
||||||
// 2. Sync to server with the same timestamped object
|
// 2. Sync to server with the same timestamped object (not while a conflict is waiting on a choice)
|
||||||
setSyncStatus('syncing');
|
if (conflictRef.current?.id === toSave.id) return;
|
||||||
const result = await saveRemoteProject(toSave);
|
await pushProject(toSave);
|
||||||
setSyncStatus(result.ok ? 'synced' : 'error');
|
|
||||||
// On success clear after 2.5s; on error keep visible until next successful sync
|
|
||||||
if (result.ok) window.setTimeout(() => setSyncStatus(''), 2500);
|
|
||||||
}, 1000);
|
}, 1000);
|
||||||
return () => {
|
return () => {
|
||||||
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
||||||
};
|
};
|
||||||
}, [project]);
|
}, [project]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// The two ways out of a conflict: keep this device's version, or take the server's.
|
||||||
|
const keepMyVersion = async () => {
|
||||||
|
if (!project) return;
|
||||||
|
await pushProject({ ...project, lastEdited: Date.now() }, { force: true });
|
||||||
|
};
|
||||||
|
const useServerVersion = async () => {
|
||||||
|
if (!syncConflict) return;
|
||||||
|
const result = await loadRemoteProject(syncConflict.id);
|
||||||
|
if (!result.ok) { toast("Couldn't load the other version. Check your connection and try again.", { tone: 'error' }); return; }
|
||||||
|
saveProjectToStorage(result.data);
|
||||||
|
setSyncBase(syncConflict.id, result.data.lastEdited);
|
||||||
|
const loaded = loadProjectById(syncConflict.id);
|
||||||
|
savedSignatureRef.current = { id: syncConflict.id, signature: signatureOf(loaded) };
|
||||||
|
setProject(loaded);
|
||||||
|
setProjectIndex(loadProjectIndex());
|
||||||
|
setSyncConflict(null);
|
||||||
|
setSyncStatus('');
|
||||||
|
toast('Switched to the version from your other device');
|
||||||
|
};
|
||||||
|
|
||||||
const updateProject = (updater) => {
|
const updateProject = (updater) => {
|
||||||
setProject((current) => (current ? updater(current) : current));
|
setProject((current) => (current ? updater(current) : current));
|
||||||
@@ -110,6 +257,91 @@ const App = () => {
|
|||||||
// Navigation
|
// Navigation
|
||||||
const openBibleReader = () => setCurrentPage('reader');
|
const openBibleReader = () => setCurrentPage('reader');
|
||||||
|
|
||||||
|
const openReadingPlans = () => setCurrentPage('reading-plans');
|
||||||
|
|
||||||
|
// Jumps straight into a specific chapter and opens the reader (no plan context).
|
||||||
|
const readReadingPlanDay = (bookAbbrev, chapter) => {
|
||||||
|
reader.jumpToReaderVerse(bookAbbrev, chapter, 1);
|
||||||
|
setCurrentPage('reader');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Opens the Reader inside a plan: on `ref` (or the day's first unread chapter),
|
||||||
|
// in the plan's own translation, with the plan strip and end-of-day card active.
|
||||||
|
const openPlanReading = (progressId, dayIndex, ref) => {
|
||||||
|
const progress = readingPlans.planProgress.find((p) => p.id === progressId);
|
||||||
|
if (!progress) return;
|
||||||
|
const schedule = getPlanSchedule(progress.planId);
|
||||||
|
// A day with nothing scheduled would leave the Reader where it was — use the nearest real day.
|
||||||
|
const day = schedule[dayIndex]?.length ? dayIndex : Math.max(0, nearestReadingDay(schedule, dayIndex));
|
||||||
|
const refs = schedule[day] ?? [];
|
||||||
|
const done = progress.completedDays.includes(day);
|
||||||
|
const read = new Set(done ? [] : progress.chaptersRead?.[day] ?? []);
|
||||||
|
const target = ref ?? refs.find((r) => !read.has(`${r.book}.${r.chapter}`)) ?? refs[0];
|
||||||
|
setReaderPlan({ progressId, dayIndex: day });
|
||||||
|
if (target) {
|
||||||
|
reader.setReaderBookAbbrev(target.book);
|
||||||
|
reader.setReaderChapter(target.chapter);
|
||||||
|
reader.setReaderSelectedVerse(null);
|
||||||
|
reader.setReaderCrossRefs(null);
|
||||||
|
}
|
||||||
|
setCurrentPage('reader');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Changing the translation while a plan is open changes that plan's translation.
|
||||||
|
const setReaderTranslation = (id) => {
|
||||||
|
if (readerPlan) readingPlans.setPlanTranslation(readerPlan.progressId, id);
|
||||||
|
else reader.setReaderTranslation(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Starts book-mode audio playback at a specific plan day's chapter, then
|
||||||
|
// shows the Home audio player. Uses handlePlayBookAudioFrom (not the reader
|
||||||
|
// player) so this works even without opening the reader first.
|
||||||
|
const listenReadingPlanDay = (bookAbbrev, chapter) => {
|
||||||
|
audio.handlePlayBookAudioFrom(bookAbbrev, chapter);
|
||||||
|
goStudies();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Builds a new chunk-builder project seeded with a reading-plan day's
|
||||||
|
// passage — one whole-chapter chunk per chapter in the day — and jumps
|
||||||
|
// straight into the Study page, skipping the manual chapter/chunk setup.
|
||||||
|
const studyReadingPlanDay = async (dayRefs, title) => {
|
||||||
|
setStudyBuildBusy(true);
|
||||||
|
try {
|
||||||
|
const chapters = await Promise.all(dayRefs.map(async ({ book, chapter }) => {
|
||||||
|
const res = await fetch(`https://bible.helloao.org/api/BSB/${book}/${chapter}.json`);
|
||||||
|
if (!res.ok) throw new Error(`Failed to load ${book} ${chapter}`);
|
||||||
|
const data = await res.json();
|
||||||
|
const verses = parseBibleChapter(data);
|
||||||
|
const bookName = bookOptions.find((b) => b.abbrev === book)?.name ?? book;
|
||||||
|
const chunk = {
|
||||||
|
id: makeId(),
|
||||||
|
startVerse: verses[0]?.number ?? 1,
|
||||||
|
endVerse: verses.at(-1)?.number ?? 1,
|
||||||
|
spilloverEndVerse: null,
|
||||||
|
observation: '', interpretation: '', application: '',
|
||||||
|
crossReferences: [], greekWords: [], generalNotes: '',
|
||||||
|
episodeNumber: '', episodeTitle: '', finalScript: '', tags: [],
|
||||||
|
};
|
||||||
|
return { book: bookName, bookAbbrev: book, chapter: String(chapter), verses, chunks: [chunk] };
|
||||||
|
}));
|
||||||
|
const newProject = {
|
||||||
|
id: makeId(),
|
||||||
|
title: title || 'Reading Plan Study',
|
||||||
|
translation: 'BSB',
|
||||||
|
lastEdited: Date.now(),
|
||||||
|
selectedChunkId: chapters[0]?.chunks[0]?.id ?? null,
|
||||||
|
chapters,
|
||||||
|
};
|
||||||
|
setProject(newProject);
|
||||||
|
setCurrentPage('study');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('studyReadingPlanDay error:', err);
|
||||||
|
toast("Couldn't load today's passage. Please try again.", { tone: 'error' });
|
||||||
|
} finally {
|
||||||
|
setStudyBuildBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const openNewProject = () => {
|
const openNewProject = () => {
|
||||||
setProject(null);
|
setProject(null);
|
||||||
setCurrentPage('setup');
|
setCurrentPage('setup');
|
||||||
@@ -120,21 +352,29 @@ const App = () => {
|
|||||||
setCurrentPage('import');
|
setCurrentPage('import');
|
||||||
};
|
};
|
||||||
|
|
||||||
const goHome = () => {
|
// The My Studies page.
|
||||||
|
const goStudies = () => {
|
||||||
setProjectIndex(loadProjectIndex());
|
setProjectIndex(loadProjectIndex());
|
||||||
setCurrentPage('home');
|
setCurrentPage('home');
|
||||||
setProject(null);
|
setProject(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// "Home": the page the user chose in Settings (My Studies, the Bible Reader, or Reading
|
||||||
|
// Plans). If they're already on it, Home means My Studies, so there is always somewhere to go.
|
||||||
|
const goHome = () => {
|
||||||
|
const target = START_PAGES[reader.startPagePref] ?? 'home';
|
||||||
|
if (target === 'home' || target === currentPage) { goStudies(); return; }
|
||||||
|
setProjectIndex(loadProjectIndex());
|
||||||
|
setProject(null);
|
||||||
|
setCurrentPage(target);
|
||||||
|
};
|
||||||
|
|
||||||
// Manual retry after a failed background sync (shown in the project header).
|
// Manual retry after a failed background sync (shown in the project header).
|
||||||
const retrySync = async () => {
|
const retrySync = async () => {
|
||||||
if (!project) return;
|
if (!project) return;
|
||||||
setSyncStatus('syncing');
|
|
||||||
const toSave = { ...project, lastEdited: Date.now() };
|
const toSave = { ...project, lastEdited: Date.now() };
|
||||||
saveProjectToStorage(toSave);
|
saveProjectToStorage(toSave);
|
||||||
const result = await saveRemoteProject(toSave);
|
await pushProject(toSave);
|
||||||
setSyncStatus(result.ok ? 'synced' : 'error');
|
|
||||||
if (result.ok) window.setTimeout(() => setSyncStatus(''), 2500);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Shared with every page via useApp(). Page-specific state lives in the
|
// Shared with every page via useApp(). Page-specific state lives in the
|
||||||
@@ -143,26 +383,45 @@ const App = () => {
|
|||||||
// Navigation
|
// Navigation
|
||||||
currentPage, setCurrentPage,
|
currentPage, setCurrentPage,
|
||||||
goHome,
|
goHome,
|
||||||
|
goStudies,
|
||||||
authServerDown,
|
authServerDown,
|
||||||
// Auth (login/2FA/settings form state lives in AuthPage and SettingsPage)
|
// Auth (login/2FA/settings form state lives in AuthPage and SettingsPage)
|
||||||
authUser, setAuthUser,
|
authUser, setAuthUser,
|
||||||
signIn,
|
signIn,
|
||||||
logout,
|
// Save any pending bookmarks/highlights to the account before signing out
|
||||||
// Save / sync status (shown in ProjectHeader)
|
logout: async () => { await reader.flushMarks(); await reader.flushPrefs(); await logout(); },
|
||||||
|
// Save / sync status (shown in the stage bar)
|
||||||
saveStatus, setSaveStatus,
|
saveStatus, setSaveStatus,
|
||||||
syncStatus,
|
syncStatus,
|
||||||
retrySync,
|
retrySync,
|
||||||
|
syncConflict, keepMyVersion, useServerVersion,
|
||||||
// Home
|
// Home
|
||||||
projectIndex,
|
projectIndex,
|
||||||
...library,
|
...library,
|
||||||
...readingPlan,
|
...readingPlans,
|
||||||
openBibleReader,
|
openBibleReader,
|
||||||
|
openReadingPlans,
|
||||||
|
readReadingPlanDay,
|
||||||
|
listenReadingPlanDay,
|
||||||
|
studyReadingPlanDay,
|
||||||
|
studyBuildBusy,
|
||||||
openImportProject,
|
openImportProject,
|
||||||
openNewProject,
|
openNewProject,
|
||||||
|
// ⌘K palette, shortcut list, and hand-offs to the Reader / Settings
|
||||||
|
paletteOpen, openPalette: () => setPaletteOpen(true),
|
||||||
|
readerRequest, clearReaderRequest: () => setReaderRequest(null),
|
||||||
|
requestReaderSearch: (text) => setReaderRequest({ search: text }),
|
||||||
|
settingsFocus, clearSettingsFocus: () => setSettingsFocus(null),
|
||||||
|
refreshProjectIndex: () => setProjectIndex(loadProjectIndex()),
|
||||||
|
openBackup: () => { setSettingsFocus('backup'); setCurrentPage('settings'); },
|
||||||
// Audio (book player on Home + chapter player in the Reader)
|
// Audio (book player on Home + chapter player in the Reader)
|
||||||
...audio,
|
...audio,
|
||||||
// Reader
|
// Reader
|
||||||
...reader,
|
...reader,
|
||||||
|
readerPlan, setReaderPlan,
|
||||||
|
readerPlanActive: !!readerPlanProgress,
|
||||||
|
openPlanReading,
|
||||||
|
setReaderTranslation,
|
||||||
// Setup (chunk-builder state lives in SetupPage via useChunkBuilder)
|
// Setup (chunk-builder state lives in SetupPage via useChunkBuilder)
|
||||||
project, setProject,
|
project, setProject,
|
||||||
allChunks,
|
allChunks,
|
||||||
@@ -176,14 +435,26 @@ const App = () => {
|
|||||||
<Suspense fallback={<PageLoading />}>
|
<Suspense fallback={<PageLoading />}>
|
||||||
<AppRouter />
|
<AppRouter />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
{paletteOpen && <CommandPalette onClose={() => setPaletteOpen(false)} />}
|
||||||
|
{helpOpen && <ShortcutsHelp onClose={() => setHelpOpen(false)} />}
|
||||||
</AppContext.Provider>
|
</AppContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Shown while a page's code downloads: the app bar's outline and a few resting blocks in the
|
||||||
|
// reading theme, so the screen doesn't flash a different colour before the page appears.
|
||||||
function PageLoading() {
|
function PageLoading() {
|
||||||
|
const { readerTheme } = useApp();
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-slate-900">
|
<div className="min-h-dvh bg-[var(--r-bg)]" style={themeVars(readerTheme)} role="status" aria-label="Loading">
|
||||||
<p className="text-sm text-slate-400">Loading…</p>
|
<div className="safe-top border-b border-[var(--r-line)] bg-[var(--r-bar)]"><div className="h-12" /></div>
|
||||||
|
<div className="mx-auto max-w-3xl animate-pulse motion-reduce:animate-none space-y-3 px-5 pt-10" aria-hidden="true">
|
||||||
|
<div className="mx-auto h-7 w-1/3 rounded bg-[var(--r-line)]" />
|
||||||
|
<div className="h-4 rounded bg-[var(--r-line)]" />
|
||||||
|
<div className="h-4 w-11/12 rounded bg-[var(--r-line)]" />
|
||||||
|
<div className="h-4 w-10/12 rounded bg-[var(--r-line)]" />
|
||||||
|
</div>
|
||||||
|
<span className="sr-only">Loading…</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -191,11 +462,15 @@ function PageLoading() {
|
|||||||
function AppRouter() {
|
function AppRouter() {
|
||||||
const { currentPage, authUser, authServerDown } = useApp();
|
const { currentPage, authUser, authServerDown } = useApp();
|
||||||
const { token: sharedViewToken, project: sharedProject, error: sharedError } = useSharedView();
|
const { token: sharedViewToken, project: sharedProject, error: sharedError } = useSharedView();
|
||||||
|
const sharedOutlineToken = new URLSearchParams(window.location.search).get('outline');
|
||||||
|
|
||||||
|
// A read-only outline link: no sign-in, nothing editable
|
||||||
|
if (sharedOutlineToken) return <SharedOutlineView token={sharedOutlineToken} />;
|
||||||
|
|
||||||
if (sharedViewToken) {
|
if (sharedViewToken) {
|
||||||
if (sharedError) {
|
if (sharedError) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-slate-900 px-4">
|
<div className="flex min-h-dvh items-center justify-center bg-slate-900 px-4">
|
||||||
<div className="w-full max-w-sm rounded-3xl border border-white/10 bg-white p-8 text-center shadow-panel">
|
<div className="w-full max-w-sm rounded-3xl border border-white/10 bg-white p-8 text-center shadow-panel">
|
||||||
<p className="text-sm uppercase tracking-[0.24em] text-slate-400">Bible Study Project</p>
|
<p className="text-sm uppercase tracking-[0.24em] text-slate-400">Bible Study Project</p>
|
||||||
<p className="mt-4 text-sm text-rose-600">{sharedError}</p>
|
<p className="mt-4 text-sm text-rose-600">{sharedError}</p>
|
||||||
@@ -205,13 +480,13 @@ function AppRouter() {
|
|||||||
}
|
}
|
||||||
if (!sharedProject) {
|
if (!sharedProject) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-slate-900">
|
<div className="flex min-h-dvh items-center justify-center bg-slate-900">
|
||||||
<p className="text-sm text-slate-400">Loading shared study…</p>
|
<p className="text-sm text-slate-400">Loading shared study…</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-100">
|
<div className="min-h-dvh bg-slate-100">
|
||||||
<div className="border-b border-slate-200 bg-slate-900 px-4 py-3 text-center text-xs text-slate-300">
|
<div className="border-b border-slate-200 bg-slate-900 px-4 py-3 text-center text-xs text-slate-300">
|
||||||
🔗 Read-only shared view —{' '}
|
🔗 Read-only shared view —{' '}
|
||||||
<a href="/" className="underline hover:text-white">go to Bible Study Project</a>
|
<a href="/" className="underline hover:text-white">go to Bible Study Project</a>
|
||||||
@@ -220,30 +495,35 @@ function AppRouter() {
|
|||||||
title="Shared study"
|
title="Shared study"
|
||||||
srcDoc={buildExportHtml(sharedProject)}
|
srcDoc={buildExportHtml(sharedProject)}
|
||||||
sandbox="allow-popups"
|
sandbox="allow-popups"
|
||||||
className="h-[calc(100vh-2.5rem)] w-full border-0"
|
className="h-[calc(100dvh-2.5rem)] w-full border-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authUser === undefined && !authServerDown) {
|
if (authUser === undefined && !authServerDown) return <PageLoading />;
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen items-center justify-center bg-slate-900">
|
|
||||||
<p className="text-sm text-slate-400">Loading…</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (authUser === null && !authServerDown) return <AuthPage />;
|
if (authUser === null && !authServerDown) return <AuthPage />;
|
||||||
|
|
||||||
if (currentPage === 'admin') return <AdminPage />;
|
if (currentPage === 'admin') return <AdminPage />;
|
||||||
if (currentPage === 'settings') return <SettingsPage />;
|
if (currentPage === 'settings') return <SettingsPage />;
|
||||||
|
if (currentPage === 'reading-plans') return <ReadingPlansPage />;
|
||||||
if (currentPage === 'reader') return <ReaderPage />;
|
if (currentPage === 'reader') return <ReaderPage />;
|
||||||
if (currentPage === 'import') return <ImportPage />;
|
if (currentPage === 'import') return <ImportPage />;
|
||||||
if (currentPage === 'study') return <StudyPage />;
|
if (currentPage === 'study') return <StudyPage />;
|
||||||
|
if (currentPage === 'outline') return <OutlinePage />;
|
||||||
|
if (currentPage === 'script') return <ScriptPage />;
|
||||||
|
if (currentPage === 'export') return <ExportPage />;
|
||||||
if (currentPage === 'setup') return <SetupPage />;
|
if (currentPage === 'setup') return <SetupPage />;
|
||||||
if (currentPage === 'pronunciation') return <PronunciationPage />;
|
if (currentPage === 'pronunciation') return <PronunciationPage />;
|
||||||
return <HomePage />;
|
return <HomePage />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The toast provider sits outside so every page (and App itself) can show toasts.
|
||||||
|
const App = () => (
|
||||||
|
<ToastProvider>
|
||||||
|
<AppInner />
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
@@ -76,6 +76,12 @@ async function renderApp() {
|
|||||||
await waitFor(() => expect(screen.queryByText('Loading…')).not.toBeInTheDocument());
|
await waitFor(() => expect(screen.queryByText('Loading…')).not.toBeInTheDocument());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The account menu (avatar button in the app bar) holds the email, Settings, Admin and Log out.
|
||||||
|
async function openAccountMenu(user = userEvent.setup()) {
|
||||||
|
await user.click(await screen.findByRole('button', { name: /account menu/i }));
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadChapter() {
|
async function loadChapter() {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
await renderApp();
|
await renderApp();
|
||||||
@@ -135,17 +141,18 @@ describe('Initial render', () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
await renderApp();
|
await renderApp();
|
||||||
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
|
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
|
||||||
expect(await screen.findByText('Project Setup')).toBeInTheDocument();
|
expect(await screen.findByText('New study')).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: /load chapter/i })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /load chapter/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('setup form shows translation, book, and chapter inputs', async () => {
|
test('setup form shows book and chapter inputs, and uses the BSB', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
await renderApp();
|
await renderApp();
|
||||||
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
|
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
|
||||||
expect(screen.getByText('Translation')).toBeInTheDocument();
|
expect(screen.queryByText('Translation')).not.toBeInTheDocument();
|
||||||
expect(screen.getByText('Book')).toBeInTheDocument();
|
expect(screen.getByText('Book')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Chapter')).toBeInTheDocument();
|
expect(screen.getByText('Chapter')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Berean Standard Bible/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -200,7 +207,7 @@ describe('Chunk management', () => {
|
|||||||
test('creates a chunk by clicking a verse range', async () => {
|
test('creates a chunk by clicking a verse range', async () => {
|
||||||
await loadChapter();
|
await loadChapter();
|
||||||
addChunk('Paul, a servant', 'Grace and peace');
|
addChunk('Paul, a servant', 'Grace and peace');
|
||||||
await screen.findByText(/1[-–]3/);
|
expect((await screen.findAllByText(/1[-–]3/)).length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('chunk count increments after each addition', async () => {
|
test('chunk count increments after each addition', async () => {
|
||||||
@@ -250,7 +257,7 @@ async function goToStudyWith3Chunks() {
|
|||||||
addChunk('Grace and peace', 'Grace and peace');
|
addChunk('Grace and peace', 'Grace and peace');
|
||||||
await screen.findByText('3 created');
|
await screen.findByText('3 created');
|
||||||
fireEvent.click(screen.getByRole('button', { name: /begin studying/i }));
|
fireEvent.click(screen.getByRole('button', { name: /begin studying/i }));
|
||||||
await screen.findByText(/chunk editor/i);
|
await findChunkCounter(3, 3);
|
||||||
// addChunk always selects the newest chunk; navigate back to chunk 1
|
// addChunk always selects the newest chunk; navigate back to chunk 1
|
||||||
// so navigation tests start from a known position.
|
// so navigation tests start from a known position.
|
||||||
fireEvent.click(screen.getByRole('button', { name: /previous chunk/i }));
|
fireEvent.click(screen.getByRole('button', { name: /previous chunk/i }));
|
||||||
@@ -264,7 +271,7 @@ describe('Study view navigation', () => {
|
|||||||
addChunk('Paul, a servant', 'In hope of eternal');
|
addChunk('Paul, a servant', 'In hope of eternal');
|
||||||
await screen.findByText('1 created');
|
await screen.findByText('1 created');
|
||||||
fireEvent.click(screen.getByRole('button', { name: /begin studying/i }));
|
fireEvent.click(screen.getByRole('button', { name: /begin studying/i }));
|
||||||
await screen.findByText(/chunk editor/i);
|
await findChunkCounter(1, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('"Previous Chunk" is disabled on the first chunk', async () => {
|
test('"Previous Chunk" is disabled on the first chunk', async () => {
|
||||||
@@ -305,8 +312,9 @@ async function goToStudyAndAddGreekWord(fetchMock) {
|
|||||||
addChunk('Paul, a servant', 'In hope of eternal');
|
addChunk('Paul, a servant', 'In hope of eternal');
|
||||||
await screen.findByText('1 created');
|
await screen.findByText('1 created');
|
||||||
fireEvent.click(screen.getByRole('button', { name: /begin studying/i }));
|
fireEvent.click(screen.getByRole('button', { name: /begin studying/i }));
|
||||||
await screen.findByText(/chunk editor/i);
|
await findChunkCounter(1, 1);
|
||||||
fireEvent.click(screen.getByRole('button', { name: /add greek word/i }));
|
fireEvent.click(screen.getByRole('tab', { name: /word study/i }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /add word/i }));
|
||||||
await screen.findByPlaceholderText(/G4102, H7225, 4102/i);
|
await screen.findByPlaceholderText(/G4102, H7225, 4102/i);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,7 +330,9 @@ describe('Greek word lookup', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: /look up greek/i }));
|
fireEvent.click(screen.getByRole('button', { name: /look up greek/i }));
|
||||||
await screen.findByDisplayValue('πίστις');
|
await screen.findByDisplayValue('πίστις');
|
||||||
expect(screen.getByDisplayValue('pistis')).toBeInTheDocument();
|
expect(screen.getByDisplayValue('pistis')).toBeInTheDocument();
|
||||||
expect(screen.getByDisplayValue('faith, belief')).toBeInTheDocument();
|
// With no gloss-map entry, the English gloss falls back to the KJV
|
||||||
|
// definition, so it matches the Short Definition field.
|
||||||
|
expect(screen.getAllByDisplayValue('faith, belief')).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shows "No definition found." when the API returns an empty array', async () => {
|
test('shows "No definition found." when the API returns an empty array', async () => {
|
||||||
@@ -408,6 +418,7 @@ describe('Authentication', () => {
|
|||||||
const u = userEvent.setup();
|
const u = userEvent.setup();
|
||||||
render(<App />);
|
render(<App />);
|
||||||
await signInWithForm(u);
|
await signInWithForm(u);
|
||||||
|
await openAccountMenu(u);
|
||||||
expect(await screen.findByText(user.email)).toBeInTheDocument();
|
expect(await screen.findByText(user.email)).toBeInTheDocument();
|
||||||
expect(calledWith(fetchMock, 'POST', '/api/auth/login')).toBe(true);
|
expect(calledWith(fetchMock, 'POST', '/api/auth/login')).toBe(true);
|
||||||
await waitFor(() => expect(calledWith(fetchMock, 'GET', '/api/reader/ink')).toBe(true));
|
await waitFor(() => expect(calledWith(fetchMock, 'GET', '/api/reader/ink')).toBe(true));
|
||||||
@@ -420,6 +431,7 @@ describe('Authentication', () => {
|
|||||||
await signInWithForm(u);
|
await signInWithForm(u);
|
||||||
await u.type(await screen.findByPlaceholderText('123456'), '123456');
|
await u.type(await screen.findByPlaceholderText('123456'), '123456');
|
||||||
await u.click(screen.getByRole('button', { name: /verify/i }));
|
await u.click(screen.getByRole('button', { name: /verify/i }));
|
||||||
|
await openAccountMenu(u);
|
||||||
expect(await screen.findByText(user.email)).toBeInTheDocument();
|
expect(await screen.findByText(user.email)).toBeInTheDocument();
|
||||||
expect(calledWith(fetchMock, 'POST', '/api/auth/mfa/verify')).toBe(true);
|
expect(calledWith(fetchMock, 'POST', '/api/auth/mfa/verify')).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -428,8 +440,9 @@ describe('Authentication', () => {
|
|||||||
const fetchMock = stubApi({ session: user });
|
const fetchMock = stubApi({ session: user });
|
||||||
const u = userEvent.setup();
|
const u = userEvent.setup();
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
await openAccountMenu(u);
|
||||||
expect(await screen.findByText(user.email)).toBeInTheDocument();
|
expect(await screen.findByText(user.email)).toBeInTheDocument();
|
||||||
await u.click(screen.getByRole('button', { name: /log out/i }));
|
await u.click(screen.getByRole('menuitem', { name: /log out/i }));
|
||||||
expect(await screen.findByRole('button', { name: /^sign in$/i })).toBeInTheDocument();
|
expect(await screen.findByRole('button', { name: /^sign in$/i })).toBeInTheDocument();
|
||||||
expect(calledWith(fetchMock, 'POST', '/api/auth/logout')).toBe(true);
|
expect(calledWith(fetchMock, 'POST', '/api/auth/logout')).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -438,7 +451,8 @@ describe('Authentication', () => {
|
|||||||
stubApi({ session: user });
|
stubApi({ session: user });
|
||||||
const u = userEvent.setup();
|
const u = userEvent.setup();
|
||||||
render(<App />);
|
render(<App />);
|
||||||
await u.click(await screen.findByRole('button', { name: /settings/i }));
|
await openAccountMenu(u);
|
||||||
|
await u.click(await screen.findByRole('menuitem', { name: /settings/i }));
|
||||||
expect(await screen.findByDisplayValue('My Show')).toBeInTheDocument();
|
expect(await screen.findByDisplayValue('My Show')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -475,7 +489,8 @@ describe('Admin page', () => {
|
|||||||
stubAdminApi(admin);
|
stubAdminApi(admin);
|
||||||
const u = userEvent.setup();
|
const u = userEvent.setup();
|
||||||
render(<App />);
|
render(<App />);
|
||||||
await u.click(await screen.findByRole('button', { name: /admin/i }));
|
await openAccountMenu(u);
|
||||||
|
await u.click(await screen.findByRole('menuitem', { name: /admin/i }));
|
||||||
expect(await screen.findByText('student@example.com')).toBeInTheDocument();
|
expect(await screen.findByText('student@example.com')).toBeInTheDocument();
|
||||||
await u.click(screen.getByRole('button', { name: /projects \(1\)/i }));
|
await u.click(screen.getByRole('button', { name: /projects \(1\)/i }));
|
||||||
expect(await screen.findByText('Romans Study')).toBeInTheDocument();
|
expect(await screen.findByText('Romans Study')).toBeInTheDocument();
|
||||||
@@ -486,18 +501,32 @@ describe('Admin page', () => {
|
|||||||
window.confirm.mockReturnValue(true);
|
window.confirm.mockReturnValue(true);
|
||||||
const u = userEvent.setup();
|
const u = userEvent.setup();
|
||||||
render(<App />);
|
render(<App />);
|
||||||
await u.click(await screen.findByRole('button', { name: /admin/i }));
|
await openAccountMenu(u);
|
||||||
|
await u.click(await screen.findByRole('menuitem', { name: /admin/i }));
|
||||||
const row = (await screen.findByText('student@example.com')).closest('tr');
|
const row = (await screen.findByText('student@example.com')).closest('tr');
|
||||||
await u.click(within(row).getByRole('button', { name: /delete/i }));
|
await u.click(within(row).getByRole('button', { name: /delete/i }));
|
||||||
await waitFor(() => expect(screen.queryByText('student@example.com')).not.toBeInTheDocument());
|
await waitFor(() => expect(screen.queryByText('student@example.com')).not.toBeInTheDocument());
|
||||||
expect(fetchMock.mock.calls.some(([url, o = {}]) => url === '/api/admin/users/u2' && o.method === 'DELETE')).toBe(true);
|
expect(fetchMock.mock.calls.some(([url, o = {}]) => url === '/api/admin/users/u2' && o.method === 'DELETE')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the Admin page leads back to the dashboard', async () => {
|
||||||
|
stubAdminApi(admin);
|
||||||
|
const u = userEvent.setup();
|
||||||
|
render(<App />);
|
||||||
|
await openAccountMenu(u);
|
||||||
|
await u.click(await screen.findByRole('menuitem', { name: /admin/i }));
|
||||||
|
await screen.findByText('student@example.com');
|
||||||
|
await u.click(screen.getByRole('button', { name: /^back$/i }));
|
||||||
|
expect(await screen.findByText('My Studies')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
test('non-admins get no Admin button', async () => {
|
test('non-admins get no Admin button', async () => {
|
||||||
stubAdminApi({ ...admin, isAdmin: false });
|
stubAdminApi({ ...admin, isAdmin: false });
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
await openAccountMenu();
|
||||||
expect(await screen.findByText(admin.email)).toBeInTheDocument();
|
expect(await screen.findByText(admin.email)).toBeInTheDocument();
|
||||||
expect(screen.queryByRole('button', { name: /admin/i })).not.toBeInTheDocument();
|
expect(screen.getByRole('menuitem', { name: /settings/i })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('menuitem', { name: /admin/i })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -505,13 +534,14 @@ describe('Admin page', () => {
|
|||||||
// Import page (state is local to ImportPage)
|
// Import page (state is local to ImportPage)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
describe('Import page', () => {
|
describe('Import page', () => {
|
||||||
test('opens from Home, lists fetched translations, and goes back', async () => {
|
test('opens from Home, uses the BSB (no translation choice), and goes back', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
await renderApp();
|
await renderApp();
|
||||||
await user.click(screen.getByRole('button', { name: /import session list/i }));
|
await user.click(screen.getByRole('button', { name: /import session list/i }));
|
||||||
expect(await screen.findByRole('heading', { name: /import session list/i })).toBeInTheDocument();
|
expect(await screen.findByRole('heading', { name: /import session list/i })).toBeInTheDocument();
|
||||||
expect(await screen.findByRole('option', { name: 'KJV' })).toBeInTheDocument();
|
expect(screen.queryByRole('option', { name: 'KJV' })).not.toBeInTheDocument();
|
||||||
await user.click(screen.getByRole('button', { name: /back/i }));
|
expect(screen.getByText(/Berean Standard Bible/)).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: /^back$/i }));
|
||||||
expect(await screen.findByRole('button', { name: /import session list/i })).toBeInTheDocument();
|
expect(await screen.findByRole('button', { name: /import session list/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -558,14 +588,17 @@ describe('Home page', () => {
|
|||||||
expect(JSON.parse(localStorage.getItem('bible-study-index'))[0].title).toBe('Titus Deep Dive');
|
expect(JSON.parse(localStorage.getItem('bible-study-index'))[0].title).toBe('Titus Deep Dive');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('deleting a project removes it after confirmation', async () => {
|
test('deleting a project removes it at once, and Undo brings it back', async () => {
|
||||||
seedProject();
|
seedProject();
|
||||||
window.confirm.mockReturnValue(true);
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
await renderApp();
|
await renderApp();
|
||||||
await user.click(screen.getByRole('button', { name: /^delete$/i }));
|
await user.click(screen.getByRole('button', { name: /^delete$/i }));
|
||||||
await waitFor(() => expect(screen.queryByText('Titus Notes')).not.toBeInTheDocument());
|
await waitFor(() => expect(screen.queryByText('Titus Notes')).not.toBeInTheDocument());
|
||||||
expect(localStorage.getItem('bible-study-project-p1')).toBeNull();
|
expect(localStorage.getItem('bible-study-project-p1')).toBeNull();
|
||||||
|
expect(window.confirm).not.toHaveBeenCalled();
|
||||||
|
await user.click(await screen.findByRole('button', { name: /undo/i }));
|
||||||
|
expect(await screen.findByText('Titus Notes')).toBeInTheDocument();
|
||||||
|
expect(localStorage.getItem('bible-study-project-p1')).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('sort choice is remembered after leaving Home', async () => {
|
test('sort choice is remembered after leaving Home', async () => {
|
||||||
@@ -574,30 +607,66 @@ describe('Home page', () => {
|
|||||||
await renderApp();
|
await renderApp();
|
||||||
await user.selectOptions(screen.getByDisplayValue('Last edited'), 'title');
|
await user.selectOptions(screen.getByDisplayValue('Last edited'), 'title');
|
||||||
await user.click(screen.getByRole('button', { name: /read bible/i }));
|
await user.click(screen.getByRole('button', { name: /read bible/i }));
|
||||||
await user.click(await screen.findByRole('button', { name: /back to studies/i }));
|
await user.click(await screen.findByRole('button', { name: /back to home/i }));
|
||||||
expect(await screen.findByDisplayValue('Title')).toBeInTheDocument();
|
expect(await screen.findByDisplayValue('Title')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a reading plan can be started from Home', async () => {
|
test('Continue reading opens the Reader inside the plan, in the plan\'s own translation', async () => {
|
||||||
seedProject(); // the plan card shows once there's at least one project
|
seedProject();
|
||||||
|
localStorage.setItem('readingPlanProgress', JSON.stringify({
|
||||||
|
progress: [{ id: 'rp1', planId: 'gospels-30', startedAt: Date.now(), completedDays: [], chaptersRead: {}, completedAt: null, lastActivityAt: Date.now(), translation: 'ESV' }],
|
||||||
|
customPlans: [], activity: [],
|
||||||
|
}));
|
||||||
|
const esvChapter = { translation: 'ESV', verses: [{ number: 1, text: 'In the beginning was the Word.' }], copyright: '' };
|
||||||
|
const fetchMock = vi.fn((url) => {
|
||||||
|
if (String(url).startsWith('/api/bible/ESV/')) return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(esvChapter) });
|
||||||
|
return buildFetchMock()(url);
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
await renderApp();
|
await renderApp();
|
||||||
await user.click(screen.getByRole('button', { name: /set goal/i }));
|
await user.click(screen.getByRole('button', { name: /reading plans/i }));
|
||||||
await user.click(screen.getByRole('button', { name: /start plan/i }));
|
await user.click(await screen.findByRole('button', { name: /continue reading/i }));
|
||||||
await waitFor(() => expect(JSON.parse(localStorage.getItem('readingPlan'))?.bookAbbrev).toBe('GEN'));
|
// The plan strip is up, on the plan's translation, and the chapter came from the ESV route
|
||||||
|
expect(await screen.findByText(/In the beginning was the Word/)).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText(/The Gospels in 30 Days · Day 1/).length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByText('ESV')).toBeInTheDocument(); // plan strip shows the plan's translation
|
||||||
|
expect(fetchMock.mock.calls.some(([u]) => String(u).startsWith('/api/bible/ESV/MAT/1'))).toBe(true);
|
||||||
|
// Leaving the reader ends the plan context: the reader's own translation applies again
|
||||||
|
await user.click(screen.getByRole('button', { name: /back to home/i }));
|
||||||
|
await user.click(screen.getByRole('button', { name: /read bible/i }));
|
||||||
|
expect(await screen.findByText(/Matthew 1 · BSB/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a reading plan can be started from the Reading Plans page', async () => {
|
||||||
|
seedProject(); // the nav button shows once there's at least one project
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await user.click(screen.getByRole('button', { name: /reading plans/i }));
|
||||||
|
// Tap a plan in the catalog, pick a translation for it, and start it.
|
||||||
|
await user.click(await screen.findByRole('button', { name: /The Gospels in 30 Days/ }));
|
||||||
|
await user.selectOptions(screen.getByRole('combobox'), 'ESV');
|
||||||
|
await user.click(screen.getByRole('button', { name: /^start plan$/i }));
|
||||||
|
await waitFor(() => {
|
||||||
|
const stored = JSON.parse(localStorage.getItem('readingPlanProgress'));
|
||||||
|
expect(stored?.progress?.[0]?.planId).toBe('gospels-30');
|
||||||
|
expect(stored?.progress?.[0]?.translation).toBe('ESV');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Project header (ProjectHeader + useShareLink) and the shared read-only view
|
// Export stage (useShareLink) and the shared read-only view
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
describe('Project header and sharing', () => {
|
describe('Project header and sharing', () => {
|
||||||
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
|
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
|
||||||
|
|
||||||
test('Export → Markdown downloads a file', async () => {
|
test('Export stage → Markdown downloads a file', async () => {
|
||||||
const user = await loadChapter();
|
const user = await loadChapter();
|
||||||
await user.click(screen.getByRole('button', { name: /export/i }));
|
addChunk('Paul, a servant', 'Paul, a servant');
|
||||||
await user.click(screen.getByRole('button', { name: /export markdown/i }));
|
await screen.findByText('1 created');
|
||||||
|
await user.click(screen.getByRole('button', { name: /^export$/i }));
|
||||||
|
await user.click(await screen.findByRole('button', { name: /export markdown/i }));
|
||||||
expect(URL.createObjectURL).toHaveBeenCalled();
|
expect(URL.createObjectURL).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -611,8 +680,10 @@ describe('Project header and sharing', () => {
|
|||||||
});
|
});
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
const user = await loadChapter();
|
const user = await loadChapter();
|
||||||
await user.click(screen.getByRole('button', { name: /share/i }));
|
addChunk('Paul, a servant', 'Paul, a servant');
|
||||||
await user.click(screen.getByRole('button', { name: /enable sharing/i }));
|
await screen.findByText('1 created');
|
||||||
|
await user.click(screen.getByRole('button', { name: /^export$/i }));
|
||||||
|
await user.click(await screen.findByRole('button', { name: /enable sharing/i }));
|
||||||
expect(await screen.findByDisplayValue(/\?share=tok123$/)).toBeInTheDocument();
|
expect(await screen.findByDisplayValue(/\?share=tok123$/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -639,6 +710,7 @@ describe('Study editor', () => {
|
|||||||
test('study-page messages now show as a status toast', async () => {
|
test('study-page messages now show as a status toast', async () => {
|
||||||
await goToStudyWith3Chunks();
|
await goToStudyWith3Chunks();
|
||||||
// Titus is a New Testament book, so the Hebrew picker explains why it can't help.
|
// Titus is a New Testament book, so the Hebrew picker explains why it can't help.
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: /word study/i }));
|
||||||
fireEvent.click(screen.getByRole('button', { name: /suggest hebrew/i }));
|
fireEvent.click(screen.getByRole('button', { name: /suggest hebrew/i }));
|
||||||
expect(await screen.findByRole('status')).toHaveTextContent(/old testament/i);
|
expect(await screen.findByRole('status')).toHaveTextContent(/old testament/i);
|
||||||
});
|
});
|
||||||
@@ -653,6 +725,7 @@ describe('Study editor', () => {
|
|||||||
|
|
||||||
test('a typed cross-reference is added, and the input clears on chunk change', async () => {
|
test('a typed cross-reference is added, and the input clears on chunk change', async () => {
|
||||||
await goToStudyWith3Chunks();
|
await goToStudyWith3Chunks();
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: /cross-refs/i }));
|
||||||
const input = screen.getByPlaceholderText(/john 1:1 or rom 3:23/i);
|
const input = screen.getByPlaceholderText(/john 1:1 or rom 3:23/i);
|
||||||
fireEvent.change(input, { target: { value: 'Rom 3:23' } });
|
fireEvent.change(input, { target: { value: 'Rom 3:23' } });
|
||||||
fireEvent.keyDown(input, { key: 'Enter' });
|
fireEvent.keyDown(input, { key: 'Enter' });
|
||||||
@@ -664,6 +737,7 @@ describe('Study editor', () => {
|
|||||||
|
|
||||||
test('a tag can be added with Enter', async () => {
|
test('a tag can be added with Enter', async () => {
|
||||||
await goToStudyWith3Chunks();
|
await goToStudyWith3Chunks();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /^more/i }));
|
||||||
const input = screen.getByPlaceholderText(/add a tag/i);
|
const input = screen.getByPlaceholderText(/add a tag/i);
|
||||||
fireEvent.change(input, { target: { value: 'grace' } });
|
fireEvent.change(input, { target: { value: 'grace' } });
|
||||||
fireEvent.keyDown(input, { key: 'Enter' });
|
fireEvent.keyDown(input, { key: 'Enter' });
|
||||||
@@ -686,17 +760,682 @@ describe('Chunk builder', () => {
|
|||||||
|
|
||||||
test('"Add Chapter" starts on the next chapter and adds it as a tab', async () => {
|
test('"Add Chapter" starts on the next chapter and adds it as a tab', async () => {
|
||||||
const user = await loadChapter();
|
const user = await loadChapter();
|
||||||
expect(await addSecondChapter(user)).toHaveClass('bg-slate-900');
|
expect(await addSecondChapter(user)).toHaveAttribute('aria-current', 'true');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('"Back to Chunks" reopens the chapter of the chunk being studied', async () => {
|
test('the Chunks stage reopens the chapter of the chunk being studied', async () => {
|
||||||
const user = await loadChapter();
|
const user = await loadChapter();
|
||||||
await addSecondChapter(user);
|
await addSecondChapter(user);
|
||||||
addChunk('Paul, a servant', 'In hope of eternal'); // chunk in Titus 2 (now selected)
|
addChunk('Paul, a servant', 'In hope of eternal'); // chunk in Titus 2 (now selected)
|
||||||
await user.click(screen.getByRole('button', { name: 'Titus 1' }));
|
await user.click(screen.getByRole('button', { name: 'Titus 1' }));
|
||||||
await user.click(screen.getByRole('button', { name: /begin studying/i }));
|
await user.click(screen.getByRole('button', { name: /begin studying/i }));
|
||||||
await screen.findByText(/chunk editor/i);
|
await findChunkCounter(1, 1);
|
||||||
await user.click(screen.getByRole('button', { name: /back to chunks/i }));
|
await user.click(screen.getByRole('button', { name: /^chunks$/i }));
|
||||||
expect(await screen.findByRole('button', { name: 'Titus 2' })).toHaveClass('bg-slate-900');
|
expect(await screen.findByRole('button', { name: 'Titus 2' })).toHaveAttribute('aria-current', 'true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Stage bar (Chunks / Study / Outline / Script / Export)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('Stage bar', () => {
|
||||||
|
test('later stages are disabled until a chunk exists', async () => {
|
||||||
|
await loadChapter();
|
||||||
|
for (const name of [/^study$/i, /^outline$/i, /^script$/i, /^export$/i]) {
|
||||||
|
expect(screen.getByRole('button', { name })).toBeDisabled();
|
||||||
|
}
|
||||||
|
expect(screen.getByRole('button', { name: /^chunks$/i })).not.toBeDisabled();
|
||||||
|
addChunk('Paul, a servant', 'Paul, a servant');
|
||||||
|
await screen.findByText('1 created');
|
||||||
|
expect(screen.getByRole('button', { name: /^study$/i })).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Script stage opens the Pronunciation Guide, and back returns to Script', async () => {
|
||||||
|
const user = await loadChapter();
|
||||||
|
addChunk('Paul, a servant', 'Paul, a servant');
|
||||||
|
await screen.findByText('1 created');
|
||||||
|
await user.click(screen.getByRole('button', { name: /^script$/i }));
|
||||||
|
await user.click(await screen.findByRole('button', { name: /pronunciation guide/i }));
|
||||||
|
expect(await screen.findByRole('heading', { name: /pronunciation guide/i })).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: /back to script/i }));
|
||||||
|
expect(await screen.findByLabelText(/final script/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a script written in the Script stage is kept on the chunk', async () => {
|
||||||
|
const user = await loadChapter();
|
||||||
|
addChunk('Paul, a servant', 'Paul, a servant');
|
||||||
|
await screen.findByText('1 created');
|
||||||
|
await user.click(screen.getByRole('button', { name: /^script$/i }));
|
||||||
|
const script = await screen.findByLabelText(/final script/i);
|
||||||
|
fireEvent.change(script, { target: { value: 'Welcome to the episode.' } });
|
||||||
|
await user.click(screen.getByRole('button', { name: /^study$/i }));
|
||||||
|
await user.click(screen.getByRole('button', { name: /^script$/i }));
|
||||||
|
expect(await screen.findByDisplayValue('Welcome to the episode.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Study workspace: outline editing, notes accordion, and the resource tabs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('Study workspace', () => {
|
||||||
|
const okJson = (data) => Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(data), headers: { get: () => 'application/json' } });
|
||||||
|
// Wraps the default mock so individual tests can answer specific URLs.
|
||||||
|
const withRoutes = (routes) => {
|
||||||
|
const base = buildFetchMock();
|
||||||
|
return vi.fn((url, opts) => {
|
||||||
|
for (const [needle, data] of routes) if (String(url).includes(needle)) return okJson(data);
|
||||||
|
return base(url, opts);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
async function studyOneChunk(routes = []) {
|
||||||
|
if (routes.length) vi.stubGlobal('fetch', withRoutes(routes));
|
||||||
|
const user = await loadChapter();
|
||||||
|
addChunk('Paul, a servant', 'In hope of eternal');
|
||||||
|
await screen.findByText('1 created');
|
||||||
|
await user.click(screen.getByRole('button', { name: /begin studying/i }));
|
||||||
|
await findChunkCounter(1, 1);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('outline: clicking between verses ends a chunk; clicking again joins them', async () => {
|
||||||
|
const user = await loadChapter();
|
||||||
|
await user.click(screen.getByRole('button', { name: /end a chunk after verse 1/i }));
|
||||||
|
await screen.findByText('1 created');
|
||||||
|
await user.click(screen.getByRole('button', { name: /end a chunk after verse 2/i }));
|
||||||
|
await screen.findByText('2 created');
|
||||||
|
await user.click(screen.getByRole('button', { name: /join chunks at verse 1/i }));
|
||||||
|
await screen.findByText('1 created');
|
||||||
|
expect((await screen.findAllByText(/1[-–]2/)).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('outline: "Suggest from headings" fills the chapter, and never touches existing chunks', async () => {
|
||||||
|
const user = await loadChapter();
|
||||||
|
addChunk('Paul, a servant', 'Paul, a servant');
|
||||||
|
await screen.findByText('1 created');
|
||||||
|
await user.click(screen.getByRole('button', { name: /suggest from headings/i }));
|
||||||
|
await screen.findByText('2 created');
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent(/added 1 chunk/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('notes: one section is open at a time, and text is kept when switching', async () => {
|
||||||
|
const user = await studyOneChunk();
|
||||||
|
fireEvent.change(screen.getByRole('textbox', { name: 'Observe' }), { target: { value: 'Paul calls himself a servant.' } });
|
||||||
|
await user.click(screen.getByRole('button', { name: /interpret/i }));
|
||||||
|
expect(screen.queryByRole('textbox', { name: 'Observe' })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('textbox', { name: 'Interpret' })).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: /observe/i }));
|
||||||
|
expect(screen.getByRole('textbox', { name: 'Observe' })).toHaveValue('Paul calls himself a servant.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('notes: the outline dots and word count reflect what you wrote', async () => {
|
||||||
|
await studyOneChunk();
|
||||||
|
fireEvent.change(screen.getByRole('textbox', { name: 'Observe' }), { target: { value: 'one two three' } });
|
||||||
|
expect(screen.getByText('3 words')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('interlinear: clicking an original word adds it to the word study', async () => {
|
||||||
|
const user = await studyOneChunk([['/interlinear/TIT.json', { 1: { 1: [{ o: 'Παῦλος', t: 'Paulos', g: 'Paul', s: 'G3972' }] } }]]);
|
||||||
|
await user.click(await screen.findByRole('button', { name: '1' })); // verse number opens its original words
|
||||||
|
await user.click(await screen.findByRole('button', { name: /Παῦλος/ }));
|
||||||
|
expect(await screen.findByRole('status')).toHaveTextContent(/added/i);
|
||||||
|
expect(screen.getByRole('tab', { name: /word study · 1/i })).toBeInTheDocument();
|
||||||
|
expect(within(screen.getByRole('tabpanel')).getByRole('button', { name: /Παῦλος/ })).toBeInTheDocument(); // the new card
|
||||||
|
});
|
||||||
|
|
||||||
|
test('commentary: "Add to notes" quotes a paragraph with its citation', async () => {
|
||||||
|
const user = await studyOneChunk([['/api/c/matthew-henry/TIT/1.json', { chapter: { content: [{ type: 'verse', number: 1, content: ['Paul was an apostle.'] }] } }]]);
|
||||||
|
await user.click(await screen.findByRole('button', { name: /add to notes/i }));
|
||||||
|
expect(await screen.findByRole('status')).toHaveTextContent(/background notes/i);
|
||||||
|
await user.click(screen.getByRole('button', { name: /^more/i }));
|
||||||
|
expect(screen.getByLabelText(/background/i)).toHaveValue('Paul was an apostle. (Matthew Henry, Titus 1:1)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('people & places: lists what is tagged in the chunk', async () => {
|
||||||
|
const user = await studyOneChunk([['/d/theographic/TIT/1.json', { chapter: { people: [{ id: 'paul_1', name: 'Paul', verses: [1] }, { id: 'x_2', name: 'Elsewhere', verses: [9] }], places: [], events: [] } }]]);
|
||||||
|
await user.click(screen.getByRole('tab', { name: /people & places/i }));
|
||||||
|
const panel = screen.getByRole('tabpanel');
|
||||||
|
expect(await within(panel).findByRole('button', { name: /paul/i })).toBeInTheDocument();
|
||||||
|
expect(within(panel).queryByText('Elsewhere')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('script: "Build outline from notes" starts the script from your notes', async () => {
|
||||||
|
const user = await studyOneChunk();
|
||||||
|
fireEvent.change(screen.getByRole('textbox', { name: 'Observe' }), { target: { value: 'Paul greets Titus.' } });
|
||||||
|
await user.click(screen.getByRole('button', { name: /^script$/i }));
|
||||||
|
await user.click(await screen.findByRole('button', { name: /build outline from notes/i }));
|
||||||
|
const script = screen.getByLabelText(/final script/i);
|
||||||
|
expect(script.value).toContain('WHAT THE TEXT SAYS\nPaul greets Titus.');
|
||||||
|
expect(screen.getByText(/\d+ words · about/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// App bar and study layout
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('App bar', () => {
|
||||||
|
test('shows the three destinations, and the account menu switches the theme', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
expect(screen.getByRole('button', { name: /^studies$/i })).toHaveAttribute('aria-current', 'page');
|
||||||
|
expect(screen.getByRole('button', { name: /read bible/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /reading plans/i })).toBeInTheDocument();
|
||||||
|
await openAccountMenu(user);
|
||||||
|
await user.click(screen.getByRole('button', { name: /^dark$/i }));
|
||||||
|
expect(localStorage.getItem('reader-theme')).toBe('dark');
|
||||||
|
// Local-only mode has no signed-in user, so there is no Settings/Log out to show
|
||||||
|
expect(screen.queryByRole('menuitem', { name: /log out/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the account menu closes on Escape', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await openAccountMenu(user);
|
||||||
|
expect(screen.getByRole('button', { name: /^paper$/i })).toBeInTheDocument();
|
||||||
|
await user.keyboard('{Escape}');
|
||||||
|
expect(screen.queryByRole('button', { name: /^paper$/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the brand button and Studies return Home from another page', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await user.click(screen.getByRole('button', { name: /import session list/i }));
|
||||||
|
await screen.findByRole('heading', { name: /import session list/i });
|
||||||
|
await user.click(screen.getByRole('button', { name: /bible study home/i }));
|
||||||
|
expect(await screen.findByText('My Studies')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Study layout', () => {
|
||||||
|
test('notes sit in their own box above the resources, not under the passage', async () => {
|
||||||
|
const u = await loadChapter();
|
||||||
|
addChunk('Paul, a servant', 'In hope of eternal');
|
||||||
|
await screen.findByText('1 created');
|
||||||
|
await u.click(screen.getByRole('button', { name: /begin studying/i }));
|
||||||
|
await findChunkCounter(1, 1);
|
||||||
|
const notes = screen.getByRole('region', { name: 'Notes' });
|
||||||
|
const resources = screen.getByRole('region', { name: 'Resources' });
|
||||||
|
expect(within(notes).getByRole('textbox', { name: 'Observe' })).toBeInTheDocument();
|
||||||
|
expect(within(resources).getByRole('tab', { name: /commentary/i })).toBeInTheDocument();
|
||||||
|
// notes come first in the right column
|
||||||
|
expect(notes.compareDocumentPosition(resources) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
|
// and the passage column no longer holds them
|
||||||
|
expect(within(screen.getByRole('region', { name: 'Scripture' })).queryByRole('textbox')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Reading plan with more days than chapters', () => {
|
||||||
|
test('Continue reading opens the plan on a real chapter instead of stranding the Reader', async () => {
|
||||||
|
localStorage.setItem('readingPlanProgress', JSON.stringify({
|
||||||
|
customPlans: [{ id: 'custom-luke', title: 'Luke', days: 90, books: ['LUK'], custom: true }],
|
||||||
|
progress: [{ id: 'rp1', planId: 'custom-luke', startedAt: Date.now(), completedDays: [0, 1], chaptersRead: {}, completedAt: null, lastActivityAt: Date.now() }],
|
||||||
|
activity: [],
|
||||||
|
}));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await user.click(await screen.findByRole('button', { name: /continue reading/i }));
|
||||||
|
expect(await screen.findByText(/Luke 3 · BSB/)).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText(/Luke · Day 3/).length).toBeGreaterThan(0);
|
||||||
|
expect(screen.queryByText(/back to today.s reading/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bookmarks and highlights follow the account
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('Bookmark and highlight sync', () => {
|
||||||
|
const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: null, isAdmin: false };
|
||||||
|
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
|
||||||
|
const hl = (over = {}) => ({ id: 'h1', book: 'JHN', chapter: 3, verse: 16, startOffset: 0, endOffset: 5, color: '#fef08a', ...over });
|
||||||
|
|
||||||
|
// A fake API that keeps the account's marks, like the real server does.
|
||||||
|
function stubServer(initial = { bookmarks: {}, highlights: [], updatedAt: 0 }) {
|
||||||
|
const server = { ...initial, puts: [], prefs: initial.prefs ?? {}, prefPuts: [] };
|
||||||
|
const fallback = buildFetchMock();
|
||||||
|
const fetchMock = vi.fn((url, opts = {}) => {
|
||||||
|
if (!url.startsWith('/api/')) return fallback(url, opts);
|
||||||
|
const route = `${opts.method ?? 'GET'} ${url}`;
|
||||||
|
if (route === 'GET /api/auth/me') return json(200, user);
|
||||||
|
if (route === 'POST /api/auth/logout') return json(200, { ok: true });
|
||||||
|
if (route === 'GET /api/projects') return json(200, []);
|
||||||
|
if (route === 'GET /api/reader/ink') return json(200, {});
|
||||||
|
if (route === 'GET /api/reader/marks') return json(200, { bookmarks: server.bookmarks, highlights: server.highlights, updatedAt: server.updatedAt });
|
||||||
|
if (route === 'GET /api/prefs') return json(200, { prefs: server.prefs });
|
||||||
|
if (route === 'PUT /api/prefs') {
|
||||||
|
const patch = JSON.parse(opts.body).prefs;
|
||||||
|
server.prefPuts.push(patch);
|
||||||
|
server.prefs = { ...server.prefs, ...patch };
|
||||||
|
return json(200, { prefs: server.prefs });
|
||||||
|
}
|
||||||
|
if (route === 'PUT /api/reader/marks') {
|
||||||
|
const body = JSON.parse(opts.body);
|
||||||
|
server.puts.push(body);
|
||||||
|
server.bookmarks = body.bookmarks;
|
||||||
|
server.highlights = body.highlights;
|
||||||
|
server.updatedAt = Math.max(Date.now(), server.updatedAt + 1);
|
||||||
|
return json(200, { ok: true, updatedAt: server.updatedAt });
|
||||||
|
}
|
||||||
|
return json(404, { error: 'not mocked' });
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
const local = (key) => JSON.parse(localStorage.getItem(key) ?? 'null');
|
||||||
|
|
||||||
|
test('a new device gets the account\'s bookmarks and highlights when you sign in', async () => {
|
||||||
|
stubServer({ bookmarks: { 'JHN-3-16': '#fef08a' }, highlights: [hl()], updatedAt: 500 });
|
||||||
|
await renderApp();
|
||||||
|
await waitFor(() => expect(local('reader-bookmarks')).toEqual({ 'JHN-3-16': '#fef08a' }));
|
||||||
|
expect(local('readerTextHighlights')).toEqual([hl()]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('marks already on this device are saved to an account that has none yet', async () => {
|
||||||
|
localStorage.setItem('reader-bookmarks', JSON.stringify({ 'GEN-1-1': '#bbf7d0' }));
|
||||||
|
const server = stubServer();
|
||||||
|
await renderApp();
|
||||||
|
await waitFor(() => expect(server.puts).toHaveLength(1));
|
||||||
|
expect(server.puts[0].bookmarks).toEqual({ 'GEN-1-1': '#bbf7d0' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('marks on both sides are merged, and the merge is saved back', async () => {
|
||||||
|
localStorage.setItem('reader-bookmarks', JSON.stringify({ 'GEN-1-1': '#bbf7d0' }));
|
||||||
|
const server = stubServer({ bookmarks: { 'JHN-3-16': '#fef08a' }, highlights: [], updatedAt: 500 });
|
||||||
|
await renderApp();
|
||||||
|
await waitFor(() => expect(server.puts).toHaveLength(1));
|
||||||
|
expect(server.puts[0].bookmarks).toEqual({ 'GEN-1-1': '#bbf7d0', 'JHN-3-16': '#fef08a' });
|
||||||
|
expect(local('reader-bookmarks')).toEqual({ 'GEN-1-1': '#bbf7d0', 'JHN-3-16': '#fef08a' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a device that is up to date takes the newer server copy, dropping a removed bookmark', async () => {
|
||||||
|
localStorage.setItem('reader-bookmarks', JSON.stringify({ 'GEN-1-1': '#bbf7d0' }));
|
||||||
|
localStorage.setItem('reader-marks-synced-at', '100');
|
||||||
|
const server = stubServer({ bookmarks: {}, highlights: [], updatedAt: 900 }); // removed on another device
|
||||||
|
await renderApp();
|
||||||
|
await waitFor(() => expect(local('reader-bookmarks')).toEqual({}));
|
||||||
|
expect(server.puts).toHaveLength(0); // nothing to send back
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bookmarking a verse in the Reader saves it to the account', async () => {
|
||||||
|
const server = stubServer({ bookmarks: {}, highlights: [], updatedAt: 500 });
|
||||||
|
localStorage.setItem('reader-marks-synced-at', '500');
|
||||||
|
const u = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await u.click(screen.getByRole('button', { name: /read bible/i }));
|
||||||
|
await u.click(await screen.findByText(/Paul, a servant of God/));
|
||||||
|
await u.click(await screen.findByRole('button', { name: /bookmark verse/i }));
|
||||||
|
await waitFor(() => expect(server.puts).toHaveLength(1), { timeout: 4000 });
|
||||||
|
expect(Object.keys(server.puts[0].bookmarks)).toEqual(['GEN-1-1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('signing out clears this device\'s copy so the next person starts clean', async () => {
|
||||||
|
const server = stubServer({ bookmarks: { 'JHN-3-16': '#fef08a' }, highlights: [hl()], updatedAt: 500 });
|
||||||
|
const u = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await waitFor(() => expect(local('reader-bookmarks')).toEqual({ 'JHN-3-16': '#fef08a' }));
|
||||||
|
await u.click(await screen.findByRole('button', { name: /account menu/i }));
|
||||||
|
await u.click(await screen.findByRole('menuitem', { name: /log out/i }));
|
||||||
|
await waitFor(() => expect(localStorage.getItem('reader-marks-synced-at')).toBeNull());
|
||||||
|
expect(local('reader-bookmarks')).toEqual({});
|
||||||
|
expect(local('readerTextHighlights')).toEqual([]);
|
||||||
|
expect(server.bookmarks).toEqual({ 'JHN-3-16': '#fef08a' }); // still safe on the account
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Preference sync', () => {
|
||||||
|
const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: null, isAdmin: false };
|
||||||
|
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
|
||||||
|
|
||||||
|
function stubPrefsServer(prefs = {}) {
|
||||||
|
const server = { prefs, puts: [] };
|
||||||
|
const fallback = buildFetchMock();
|
||||||
|
vi.stubGlobal('fetch', vi.fn((url, opts = {}) => {
|
||||||
|
if (!url.startsWith('/api/')) return fallback(url, opts);
|
||||||
|
const route = `${opts.method ?? 'GET'} ${url}`;
|
||||||
|
if (route === 'GET /api/auth/me') return json(200, user);
|
||||||
|
if (route === 'POST /api/auth/logout') return json(200, { ok: true });
|
||||||
|
if (route === 'GET /api/projects') return json(200, []);
|
||||||
|
if (route === 'GET /api/reader/ink') return json(200, {});
|
||||||
|
if (route === 'GET /api/reader/marks') return json(200, { bookmarks: {}, highlights: [], updatedAt: 0 });
|
||||||
|
if (route === 'GET /api/prefs') return json(200, { prefs: server.prefs });
|
||||||
|
if (route === 'PUT /api/prefs') {
|
||||||
|
const patch = JSON.parse(opts.body).prefs;
|
||||||
|
server.puts.push(patch);
|
||||||
|
server.prefs = { ...server.prefs, ...patch };
|
||||||
|
return json(200, { prefs: server.prefs });
|
||||||
|
}
|
||||||
|
return json(404, { error: 'not mocked' });
|
||||||
|
}));
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('signing in on a new device brings your theme, translation and place in the Reader', async () => {
|
||||||
|
stubPrefsServer({
|
||||||
|
theme: { v: 'dark', at: 900 }, translation: { v: 'NASB', at: 900 }, layout: { v: 'verses', at: 900 },
|
||||||
|
position: { v: { book: 'JHN', chapter: 3 }, at: 900 },
|
||||||
|
});
|
||||||
|
const u = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await waitFor(() => expect(localStorage.getItem('reader-theme')).toBe('dark'));
|
||||||
|
expect(localStorage.getItem('reader-translation')).toBe('NASB');
|
||||||
|
expect(localStorage.getItem('reader-layout')).toBe('verses');
|
||||||
|
await u.click(screen.getByRole('button', { name: /read bible/i }));
|
||||||
|
expect(await screen.findByText(/John 3 · NASB/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changing the theme is saved to the account', async () => {
|
||||||
|
const server = stubPrefsServer({ theme: { v: 'paper', at: 10 } });
|
||||||
|
const u = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await waitFor(() => expect(server.puts.length).toBeGreaterThan(0)); // initial fill-in of the other keys
|
||||||
|
server.puts.length = 0;
|
||||||
|
await u.click(await screen.findByRole('button', { name: /account menu/i }));
|
||||||
|
await u.click(screen.getByRole('button', { name: /^sepia$/i }));
|
||||||
|
await waitFor(() => expect(server.puts).toHaveLength(1), { timeout: 4000 });
|
||||||
|
expect(server.puts[0].theme.v).toBe('sepia');
|
||||||
|
expect(Object.keys(server.puts[0])).toEqual(['theme']); // only what changed
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the commentary you pick is shared between the Reader and the Study stage', async () => {
|
||||||
|
stubPrefsServer({ commentarySource: { v: 'tyndale', at: 900 } });
|
||||||
|
await renderApp();
|
||||||
|
await waitFor(() => expect(localStorage.getItem('reader-commentary')).toBe('tyndale'));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openSettings(u) {
|
||||||
|
await u.click(await screen.findByRole('button', { name: /account menu/i }));
|
||||||
|
await u.click(await screen.findByRole('menuitem', { name: /settings/i }));
|
||||||
|
return screen.findByRole('region', { name: 'Preferences' });
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Settings shows your preferences, and choosing a default Bible saves it and syncs it', async () => {
|
||||||
|
const server = stubPrefsServer({ translation: { v: 'BSB', at: 10 } });
|
||||||
|
const u = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await waitFor(() => expect(server.puts.length).toBeGreaterThan(0));
|
||||||
|
server.puts.length = 0;
|
||||||
|
const prefs = within(await openSettings(u));
|
||||||
|
expect(prefs.getByLabelText('Default Bible')).toHaveValue('BSB');
|
||||||
|
await u.selectOptions(prefs.getByLabelText('Default Bible'), 'ESV');
|
||||||
|
expect(localStorage.getItem('reader-translation')).toBe('ESV');
|
||||||
|
await waitFor(() => expect(server.puts).toHaveLength(1), { timeout: 4000 });
|
||||||
|
expect(server.puts[0].translation.v).toBe('ESV');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('theme, verse layout, commentary and new-plan Bible can all be set from Settings', async () => {
|
||||||
|
stubPrefsServer();
|
||||||
|
const u = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
const prefs = within(await openSettings(u));
|
||||||
|
await u.click(within(prefs.getByRole('group', { name: 'Theme' })).getByRole('button', { name: 'Dark' }));
|
||||||
|
await u.click(within(prefs.getByRole('group', { name: 'Verse layout' })).getByRole('button', { name: 'One per line' }));
|
||||||
|
await u.selectOptions(prefs.getByLabelText('Commentary'), 'tyndale');
|
||||||
|
await u.selectOptions(prefs.getByLabelText('New reading plans'), 'NLT');
|
||||||
|
expect(localStorage.getItem('reader-theme')).toBe('dark');
|
||||||
|
expect(localStorage.getItem('reader-layout')).toBe('verses');
|
||||||
|
expect(localStorage.getItem('reader-commentary')).toBe('tyndale');
|
||||||
|
expect(localStorage.getItem('plan-translation')).toBe('NLT');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('text size, reading width and narrator are remembered on this device (not synced)', async () => {
|
||||||
|
const server = stubPrefsServer();
|
||||||
|
const u = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await waitFor(() => expect(server.puts.length).toBeGreaterThan(0));
|
||||||
|
server.puts.length = 0;
|
||||||
|
const prefs = within(await openSettings(u));
|
||||||
|
await u.click(within(prefs.getByRole('group', { name: 'Text size' })).getByRole('button', { name: 'XL' }));
|
||||||
|
await u.click(within(prefs.getByRole('group', { name: 'Reading width' })).getByRole('button', { name: 'Two columns' }));
|
||||||
|
await u.selectOptions(prefs.getByLabelText('Audio narrator'), 'hays');
|
||||||
|
expect(localStorage.getItem('reader-font-size')).toBe('1.25');
|
||||||
|
expect(localStorage.getItem('reader-wide')).toBe('1');
|
||||||
|
expect(localStorage.getItem('audio-narrator')).toBe('hays');
|
||||||
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
|
expect(server.puts).toHaveLength(0); // per-device settings stay off the account
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Settings leads back to the dashboard', async () => {
|
||||||
|
stubPrefsServer();
|
||||||
|
const u = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await openSettings(u);
|
||||||
|
await u.click(screen.getByRole('button', { name: /bible study home/i }));
|
||||||
|
expect(await screen.findByText('My Studies')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a new reading plan starts in the Bible chosen for new plans', async () => {
|
||||||
|
localStorage.setItem('plan-translation', 'CSB');
|
||||||
|
stubPrefsServer({ planTranslation: { v: 'CSB', at: 10 } });
|
||||||
|
const u = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await u.click(screen.getByRole('button', { name: /reading plans/i }));
|
||||||
|
await u.click(await screen.findByRole('button', { name: /The Gospels in 30 Days/ }));
|
||||||
|
expect(screen.getByRole('combobox')).toHaveValue('CSB');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('signing out forgets the sync timestamps', async () => {
|
||||||
|
stubPrefsServer({ theme: { v: 'dark', at: 900 } });
|
||||||
|
const u = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await waitFor(() => expect(localStorage.getItem('prefs-meta')).not.toBeNull());
|
||||||
|
await u.click(await screen.findByRole('button', { name: /account menu/i }));
|
||||||
|
await u.click(await screen.findByRole('menuitem', { name: /log out/i }));
|
||||||
|
await waitFor(() => expect(localStorage.getItem('prefs-meta')).toBeNull());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Every page has a way back to the dashboard
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('Back to the dashboard', () => {
|
||||||
|
const home = /back to home|bible study home/i;
|
||||||
|
const dashboard = async () => expect(await screen.findByText('My Studies')).toBeInTheDocument();
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
['the Reader', async (u) => { await u.click(screen.getByRole('button', { name: /read bible/i })); await screen.findByText(/Genesis 1 · BSB/); }],
|
||||||
|
['Reading Plans', async (u) => { await u.click(screen.getByRole('button', { name: /reading plans/i })); await screen.findByText(/browse plans/i); }],
|
||||||
|
['Import', async (u) => { await u.click(screen.getByRole('button', { name: /import session list/i })); await screen.findByRole('heading', { name: /import session list/i }); }],
|
||||||
|
])('%s', async (_name, open) => {
|
||||||
|
const u = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await open(u);
|
||||||
|
await u.click(screen.getByRole('button', { name: home }));
|
||||||
|
await dashboard();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(['Chunks', 'Study', 'Outline', 'Script', 'Export'])('the %s stage of a study', async (stage) => {
|
||||||
|
const u = await loadChapter();
|
||||||
|
addChunk('Paul, a servant', 'In hope of eternal');
|
||||||
|
await screen.findByText('1 created');
|
||||||
|
await u.click(screen.getByRole('button', { name: new RegExp(`^${stage}$`, 'i') }));
|
||||||
|
await screen.findByRole('navigation', { name: /study stages/i });
|
||||||
|
await u.click(screen.getByRole('button', { name: home }));
|
||||||
|
await dashboard();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the Pronunciation guide', async () => {
|
||||||
|
const u = await loadChapter();
|
||||||
|
addChunk('Paul, a servant', 'Paul, a servant');
|
||||||
|
await screen.findByText('1 created');
|
||||||
|
await u.click(screen.getByRole('button', { name: /^script$/i }));
|
||||||
|
await u.click(await screen.findByRole('button', { name: /pronunciation guide/i }));
|
||||||
|
await screen.findByRole('heading', { name: /pronunciation guide/i });
|
||||||
|
await u.click(screen.getByRole('button', { name: home }));
|
||||||
|
await dashboard();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The home page the user chooses
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('Chosen home page', () => {
|
||||||
|
const homeBtn = /back to home|bible study home/i;
|
||||||
|
|
||||||
|
test('the app opens on the chosen page', async () => {
|
||||||
|
localStorage.setItem('start-page', 'reader');
|
||||||
|
render(<App />);
|
||||||
|
expect(await screen.findByText(/Genesis 1 · BSB/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('My Studies')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('by default the app opens on My Studies', async () => {
|
||||||
|
await renderApp();
|
||||||
|
expect(screen.getByText('My Studies')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Home buttons go to the chosen page, and to My Studies when you are already on it', async () => {
|
||||||
|
localStorage.setItem('start-page', 'reader');
|
||||||
|
const u = userEvent.setup();
|
||||||
|
render(<App />);
|
||||||
|
await screen.findByText(/Genesis 1 · BSB/);
|
||||||
|
// already on the chosen page: Home means My Studies
|
||||||
|
await u.click(screen.getByRole('button', { name: homeBtn }));
|
||||||
|
expect(await screen.findByText('My Studies')).toBeInTheDocument();
|
||||||
|
// from another page, Home is the chosen page
|
||||||
|
await u.click(screen.getByRole('button', { name: /reading plans/i }));
|
||||||
|
await screen.findByRole('heading', { name: /^reading plans$/i });
|
||||||
|
await u.click(screen.getByRole('button', { name: homeBtn }));
|
||||||
|
expect(await screen.findByText(/Genesis 1 · BSB/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the logo on a page with an app bar goes to the chosen page, and Studies always goes to My Studies', async () => {
|
||||||
|
localStorage.setItem('start-page', 'plans');
|
||||||
|
const u = userEvent.setup();
|
||||||
|
render(<App />);
|
||||||
|
await screen.findByRole('heading', { name: /^reading plans$/i }); // opened on Reading Plans
|
||||||
|
await u.click(screen.getByRole('button', { name: homeBtn })); // already there -> My Studies
|
||||||
|
await screen.findByText('My Studies');
|
||||||
|
await u.click(screen.getByRole('button', { name: /import session list/i }));
|
||||||
|
await screen.findByRole('heading', { name: /import session list/i });
|
||||||
|
await u.click(screen.getByRole('button', { name: /bible study home/i }));
|
||||||
|
expect(await screen.findByRole('heading', { name: /^reading plans$/i })).toBeInTheDocument();
|
||||||
|
await u.click(screen.getByRole('button', { name: homeBtn })); // -> My Studies again
|
||||||
|
await u.click(screen.getByRole('button', { name: /import session list/i }));
|
||||||
|
await u.click(screen.getByRole('button', { name: /^studies$/i }));
|
||||||
|
expect(await screen.findByText('My Studies')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaving a study with a different home page closes the study', async () => {
|
||||||
|
localStorage.setItem('start-page', 'plans');
|
||||||
|
const u = userEvent.setup();
|
||||||
|
render(<App />);
|
||||||
|
await screen.findByRole('heading', { name: /^reading plans$/i });
|
||||||
|
await u.click(screen.getByRole('button', { name: homeBtn })); // to My Studies
|
||||||
|
await u.click(screen.getAllByRole('button', { name: /new project/i })[0]);
|
||||||
|
await u.click(await screen.findByRole('button', { name: /load chapter/i }));
|
||||||
|
await screen.findByText(/Scripture & Chunks/i);
|
||||||
|
await u.click(screen.getByRole('button', { name: homeBtn }));
|
||||||
|
expect(await screen.findByRole('heading', { name: /^reading plans$/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Settings changes it, and it is saved for next time', async () => {
|
||||||
|
const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: null, isAdmin: false };
|
||||||
|
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
|
||||||
|
const fallback = buildFetchMock();
|
||||||
|
vi.stubGlobal('fetch', vi.fn((url, opts = {}) => {
|
||||||
|
if (!url.startsWith('/api/')) return fallback(url, opts);
|
||||||
|
const route = `${opts.method ?? 'GET'} ${url}`;
|
||||||
|
if (route === 'GET /api/auth/me') return json(200, user);
|
||||||
|
if (route === 'GET /api/projects') return json(200, []);
|
||||||
|
if (route === 'GET /api/reader/ink') return json(200, {});
|
||||||
|
if (route === 'GET /api/reader/marks') return json(200, { bookmarks: {}, highlights: [], updatedAt: 0 });
|
||||||
|
if (route === 'GET /api/prefs') return json(200, { prefs: {} });
|
||||||
|
if (route === 'PUT /api/prefs') return json(200, { prefs: {} });
|
||||||
|
return json(404, { error: 'not mocked' });
|
||||||
|
}));
|
||||||
|
const u = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await u.click(await screen.findByRole('button', { name: /account menu/i }));
|
||||||
|
await u.click(await screen.findByRole('menuitem', { name: /settings/i }));
|
||||||
|
const prefs = within(await screen.findByRole('region', { name: 'Preferences' }));
|
||||||
|
await u.click(within(prefs.getByRole('group', { name: 'Home page' })).getByRole('button', { name: 'Reading Plans' }));
|
||||||
|
expect(localStorage.getItem('start-page')).toBe('plans');
|
||||||
|
await u.click(screen.getByRole('button', { name: homeBtn })); // Settings back -> the new home page
|
||||||
|
expect(await screen.findByRole('heading', { name: /^reading plans$/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Resizable study column', () => {
|
||||||
|
async function openStudy() {
|
||||||
|
const u = await loadChapter();
|
||||||
|
addChunk('Paul, a servant', 'In hope of eternal');
|
||||||
|
await screen.findByText('1 created');
|
||||||
|
await u.click(screen.getByRole('button', { name: /begin studying/i }));
|
||||||
|
await findChunkCounter(1, 1);
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
const columns = () => screen.getByRole('separator', { name: /resize side panel/i }).closest('aside').parentElement;
|
||||||
|
|
||||||
|
test('the handle widens and narrows the right column and the choice is remembered', async () => {
|
||||||
|
const u = await openStudy();
|
||||||
|
const handle = screen.getByRole('separator', { name: /resize side panel/i });
|
||||||
|
expect(columns().style.getPropertyValue('--study-right')).toBe(''); // default width
|
||||||
|
handle.focus();
|
||||||
|
await u.keyboard('{ArrowLeft}');
|
||||||
|
expect(columns().style.getPropertyValue('--study-right')).toBe('472px');
|
||||||
|
expect(localStorage.getItem('study-right-width')).toBe('472');
|
||||||
|
await u.keyboard('{Shift>}{ArrowRight}{/Shift}');
|
||||||
|
expect(columns().style.getPropertyValue('--study-right')).toBe('376px');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('double-clicking the handle restores the default width', async () => {
|
||||||
|
const u = await openStudy();
|
||||||
|
const handle = screen.getByRole('separator', { name: /resize side panel/i });
|
||||||
|
handle.focus();
|
||||||
|
await u.keyboard('{ArrowLeft}');
|
||||||
|
await u.dblClick(handle);
|
||||||
|
expect(localStorage.getItem('study-right-width')).toBeNull();
|
||||||
|
expect(columns().style.getPropertyValue('--study-right')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a saved width is applied when the study opens', async () => {
|
||||||
|
localStorage.setItem('study-right-width', '560');
|
||||||
|
await openStudy();
|
||||||
|
expect(columns().style.getPropertyValue('--study-right')).toBe('560px');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Import template', () => {
|
||||||
|
test('the Import page offers a Word template to download', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await user.click(screen.getByRole('button', { name: /import session list/i }));
|
||||||
|
await user.click(await screen.findByRole('button', { name: /download template/i }));
|
||||||
|
await waitFor(() => expect(URL.createObjectURL).toHaveBeenCalled());
|
||||||
|
const blob = URL.createObjectURL.mock.calls.at(-1)[0];
|
||||||
|
expect(blob.type).toMatch(/wordprocessingml/);
|
||||||
|
// and the button comes back to normal afterwards
|
||||||
|
expect(await screen.findByRole('button', { name: /download template/i })).toBeEnabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('System chrome follows the theme', () => {
|
||||||
|
test('the app tells the browser/OS the theme colour and scheme, and updates it when the theme changes', async () => {
|
||||||
|
localStorage.setItem('reader-theme', 'dark');
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
const themeColor = () => document.head.querySelector('meta[name="theme-color"]')?.getAttribute('content');
|
||||||
|
expect(themeColor()).toBe('#262421');
|
||||||
|
expect(document.documentElement.style.colorScheme).toBe('dark');
|
||||||
|
await user.click(screen.getByRole('button', { name: /account menu/i }));
|
||||||
|
await user.click(screen.getByRole('button', { name: /^paper$/i }));
|
||||||
|
expect(themeColor()).toBe('#f1ece0');
|
||||||
|
expect(document.documentElement.style.colorScheme).toBe('light');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('New pages start at the top', () => {
|
||||||
|
test('moving to another page resets the scroll position', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
const scroller = document.scrollingElement ?? document.documentElement;
|
||||||
|
scroller.scrollTop = 480; // scrolled down on the dashboard
|
||||||
|
await user.click(screen.getByRole('button', { name: /reading plans/i }));
|
||||||
|
await screen.findByRole('heading', { name: /^reading plans$/i });
|
||||||
|
expect(scroller.scrollTop).toBe(0);
|
||||||
|
scroller.scrollTop = 300;
|
||||||
|
await user.click(screen.getByRole('button', { name: /back to home|bible study home/i }));
|
||||||
|
await screen.findByText('My Studies');
|
||||||
|
expect(scroller.scrollTop).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
// A compact media-player control: big play/pause button, seekable progress
|
||||||
|
// bar, and transport buttons. Presentation only — playback state and handlers
|
||||||
|
// come from useAudio.js so the Home (whole book) and Reader (chapter) players
|
||||||
|
// can share this UI.
|
||||||
|
function formatTime(seconds) {
|
||||||
|
if (!Number.isFinite(seconds) || seconds < 0) return '0:00';
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return `${mins}:${String(secs).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" width="20" height="20" fill="currentColor">
|
||||||
|
<path d="M5.5 3.6c0-.9 1-1.45 1.76-.97l9.4 5.9c.72.45.72 1.5 0 1.94l-9.4 5.9c-.76.48-1.76-.07-1.76-.97V3.6z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PauseIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" width="20" height="20" fill="currentColor">
|
||||||
|
<rect x="4.5" y="3.5" width="4" height="13" rx="1" />
|
||||||
|
<rect x="11.5" y="3.5" width="4" height="13" rx="1" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StopIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" width="16" height="16" fill="currentColor">
|
||||||
|
<rect x="4.5" y="4.5" width="11" height="11" rx="1.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Seek10Icon({ direction }) {
|
||||||
|
const flip = direction === 'back' ? 'scale(-1,1) translate(-20,0)' : undefined;
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.6">
|
||||||
|
<g transform={flip}>
|
||||||
|
<path d="M4 8.5a6.5 6.5 0 1 1 1.2 5.3" strokeLinecap="round" />
|
||||||
|
<path d="M3 5.5v3.3h3.3" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</g>
|
||||||
|
<text x="10" y="12.2" fontSize="6.5" fontFamily="sans-serif" stroke="none" fill="currentColor" textAnchor="middle">10</text>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NextTrackIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" width="16" height="16" fill="currentColor">
|
||||||
|
<path d="M5 4.3c0-.8.9-1.3 1.6-.9l7.4 4.7c.7.4.7 1.4 0 1.8l-7.4 4.7c-.7.4-1.6-.1-1.6-.9V4.3z" />
|
||||||
|
<rect x="14" y="3.7" width="1.6" height="12.6" rx="0.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AudioPlayer({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
errorMessage,
|
||||||
|
status, // 'idle' | 'playing' | 'paused' | 'error'
|
||||||
|
currentTime = 0,
|
||||||
|
duration = 0,
|
||||||
|
onPlay,
|
||||||
|
onTogglePause,
|
||||||
|
onSeekBack,
|
||||||
|
onSeekForward,
|
||||||
|
onSeekTo,
|
||||||
|
onNext,
|
||||||
|
nextDisabled,
|
||||||
|
onStop,
|
||||||
|
controls, // optional extra controls (e.g. book/narrator selects), rendered top-right
|
||||||
|
}) {
|
||||||
|
const isActive = status === 'playing' || status === 'paused';
|
||||||
|
const progressPct = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-3xl border border-[var(--r-line,#e2e8f0)] bg-[var(--r-bar,#ffffff)] p-4 sm:p-5">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="min-w-[10rem] flex-1">
|
||||||
|
<h3 className="truncate text-sm font-semibold text-[var(--r-text,#0f172a)] sm:text-base">{title}</h3>
|
||||||
|
<p className="mt-0.5 truncate text-xs text-[var(--r-sub,#64748b)] sm:text-sm">{subtitle}</p>
|
||||||
|
{status === 'error' && errorMessage && (
|
||||||
|
<p className="mt-1 text-xs text-rose-600">{errorMessage}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{controls && <div className="flex flex-wrap items-center gap-2">{controls}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isActive && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={duration || 0}
|
||||||
|
step={0.1}
|
||||||
|
value={Math.min(currentTime, duration || 0)}
|
||||||
|
onChange={(e) => onSeekTo?.(Number(e.target.value))}
|
||||||
|
disabled={!duration}
|
||||||
|
className="h-1.5 w-full cursor-pointer appearance-none rounded-full bg-[var(--r-line,#e2e8f0)] accent-[var(--r-accent,#0284c7)] disabled:cursor-default"
|
||||||
|
style={{ background: `linear-gradient(to right, var(--r-accent, rgb(2 132 199)) ${progressPct}%, var(--r-line, rgb(226 232 240)) ${progressPct}%)` }}
|
||||||
|
aria-label="Seek"
|
||||||
|
/>
|
||||||
|
<div className="mt-1 flex justify-between text-xs tabular-nums text-[var(--r-sub,#64748b)]">
|
||||||
|
<span>{formatTime(currentTime)}</span>
|
||||||
|
<span>{formatTime(duration)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center justify-center gap-3">
|
||||||
|
{isActive ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSeekBack}
|
||||||
|
title="Rewind 10 seconds"
|
||||||
|
aria-label="Rewind 10 seconds"
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-full text-[var(--r-sub,#64748b)] hover:bg-black/5"
|
||||||
|
>
|
||||||
|
<Seek10Icon direction="back" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onTogglePause}
|
||||||
|
aria-label={status === 'paused' ? 'Resume' : 'Pause'}
|
||||||
|
className="flex h-12 w-12 items-center justify-center rounded-full bg-[var(--r-accent,#0284c7)] text-[var(--r-on,#ffffff)] hover:opacity-90"
|
||||||
|
>
|
||||||
|
{status === 'paused' ? <PlayIcon /> : <PauseIcon />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSeekForward}
|
||||||
|
title="Fast-forward 10 seconds"
|
||||||
|
aria-label="Fast-forward 10 seconds"
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-full text-[var(--r-sub,#64748b)] hover:bg-black/5"
|
||||||
|
>
|
||||||
|
<Seek10Icon direction="forward" />
|
||||||
|
</button>
|
||||||
|
{onNext && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={nextDisabled}
|
||||||
|
title="Next chapter"
|
||||||
|
aria-label="Next chapter"
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-full text-[var(--r-sub,#64748b)] hover:bg-black/5 disabled:opacity-30 disabled:hover:bg-transparent"
|
||||||
|
>
|
||||||
|
<NextTrackIcon />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onStop}
|
||||||
|
title="Stop"
|
||||||
|
aria-label="Stop"
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-full text-[var(--r-sub,#64748b)] hover:bg-black/5"
|
||||||
|
>
|
||||||
|
<StopIcon />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onPlay}
|
||||||
|
className="flex h-12 w-12 items-center justify-center rounded-full bg-[var(--r-accent,#0284c7)] text-[var(--r-on,#ffffff)] hover:opacity-90"
|
||||||
|
aria-label="Play"
|
||||||
|
>
|
||||||
|
<PlayIcon />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
// Signed-in email plus Admin / Settings / Log out buttons for page headers.
|
|
||||||
import { useApp } from '../context/AppContext.js';
|
|
||||||
|
|
||||||
export default function AuthStatus() {
|
|
||||||
const { authUser, setCurrentPage, logout } = useApp();
|
|
||||||
if (!authUser) return null;
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-slate-300">
|
|
||||||
<span className="hidden sm:inline">{authUser.email}</span>
|
|
||||||
{authUser.isAdmin && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setCurrentPage('admin')}
|
|
||||||
className="rounded-xl border border-white/15 bg-white/10 px-3 py-1.5 text-xs text-white transition hover:bg-white/15"
|
|
||||||
>
|
|
||||||
🛡 Admin
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setCurrentPage('settings')}
|
|
||||||
className="rounded-xl border border-white/15 bg-white/10 px-3 py-1.5 text-xs text-white transition hover:bg-white/15"
|
|
||||||
>
|
|
||||||
⚙ Settings
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={logout}
|
|
||||||
className="rounded-xl border border-white/15 bg-white/10 px-3 py-1.5 text-xs text-white transition hover:bg-white/15"
|
|
||||||
>
|
|
||||||
Log out
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// Press ⌘K (Ctrl+K) anywhere: type a passage ("john 3:16") to go straight to it, or a few letters to
|
||||||
|
// find a study, a note you wrote, a bookmark or highlight, a page, or to search the Bible's text.
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useApp } from '../context/AppContext.js';
|
||||||
|
import { themeVars } from './reader/readerThemes.js';
|
||||||
|
import { SearchIcon } from './reader/icons.jsx';
|
||||||
|
import { formatPassage, parsePassageRef } from '../utils/passageRef.js';
|
||||||
|
import { searchMarks, searchNotes, searchProjects } from '../utils/personalSearch.js';
|
||||||
|
import { loadProjectIndex } from '../utils/bibleUtils.jsx';
|
||||||
|
import { loadProjectById } from '../utils/projectStorage.js';
|
||||||
|
|
||||||
|
export default function CommandPalette({ onClose }) {
|
||||||
|
const {
|
||||||
|
readerTheme, authUser, authServerDown, projectIndex, readerBookmarks, readerTextHighlights, readerColorLabels,
|
||||||
|
jumpToReaderVerse, setCurrentPage, setProject, resumeProject, openNewProject, goStudies, openReadingPlans,
|
||||||
|
openBibleReader, requestReaderSearch, openBackup,
|
||||||
|
} = useApp();
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [active, setActive] = useState(0);
|
||||||
|
const listRef = useRef(null);
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
const opener = useRef(document.activeElement);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
const from = opener.current;
|
||||||
|
return () => { if (from instanceof HTMLElement && document.contains(from)) from.focus({ preventScroll: true }); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const items = useMemo(() => {
|
||||||
|
const out = [];
|
||||||
|
const q = query.trim();
|
||||||
|
const go = (fn) => () => { onClose(); fn(); };
|
||||||
|
|
||||||
|
// 1. A passage typed in
|
||||||
|
for (const r of parsePassageRef(q)) {
|
||||||
|
out.push({ group: 'Go to', label: formatPassage(r), hint: 'Open in the Bible', run: go(() => { jumpToReaderVerse(r.book, r.chapter, r.verse ?? 1); openBibleReader(); }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Pages and actions
|
||||||
|
const pages = [
|
||||||
|
['Studies', 'My Studies', goStudies], ['Read Bible', 'The Bible reader', openBibleReader], ['Reading Plans', 'Plans and today’s reading', openReadingPlans],
|
||||||
|
['New project', 'Start a Bible study', openNewProject], ['Settings', 'Account and preferences', () => setCurrentPage('settings')],
|
||||||
|
['Back up my data', 'Download everything as a file', openBackup],
|
||||||
|
];
|
||||||
|
const lower = q.toLowerCase();
|
||||||
|
for (const [label, hint, fn] of pages) {
|
||||||
|
if (!lower || label.toLowerCase().includes(lower)) out.push({ group: lower ? 'Pages' : 'Go to', label, hint, run: go(fn) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (q.length >= 2) {
|
||||||
|
// 3. Studies
|
||||||
|
for (const e of searchProjects(projectIndex, q)) {
|
||||||
|
out.push({ group: 'Studies', label: e.title, hint: e.chapterSummary, run: go(() => resumeProject(e.id)) });
|
||||||
|
}
|
||||||
|
// 4. Notes you wrote
|
||||||
|
const projects = loadProjectIndex().map((e) => loadProjectById(e.id)).filter(Boolean);
|
||||||
|
for (const h of searchNotes(projects, q)) {
|
||||||
|
out.push({
|
||||||
|
group: 'Your notes', label: `${h.ref} — ${h.projectTitle}`, hint: h.snippet,
|
||||||
|
run: go(() => {
|
||||||
|
const full = loadProjectById(h.projectId);
|
||||||
|
if (!full) return;
|
||||||
|
setProject({ ...full, selectedChunkId: h.chunkId });
|
||||||
|
setCurrentPage('study');
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 5. Bookmarks and highlights
|
||||||
|
for (const m of searchMarks({ bookmarks: readerBookmarks, highlights: readerTextHighlights, labels: readerColorLabels ?? {} }, q)) {
|
||||||
|
out.push({
|
||||||
|
group: 'Bookmarks & highlights', label: `${m.bookName} ${m.chapter}:${m.verse}`, color: m.color,
|
||||||
|
hint: m.kind === 'highlight' ? `${m.label ? `${m.label}: ` : ''}${m.text ?? 'Highlight'}` : 'Bookmark',
|
||||||
|
run: go(() => { jumpToReaderVerse(m.book, m.chapter, m.verse); openBibleReader(); }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 6. The Bible's own text
|
||||||
|
out.push({ group: 'Search', label: `Search the Bible for “${q}”`, hint: 'Whole-Bible search in the reader', run: go(() => { requestReaderSearch(q); openBibleReader(); }) });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [query]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => { setActive(0); }, [query]);
|
||||||
|
useEffect(() => { listRef.current?.querySelector('[aria-selected="true"]')?.scrollIntoView?.({ block: 'nearest' }); }, [active]);
|
||||||
|
|
||||||
|
const onKeyDown = (e) => {
|
||||||
|
if (e.key === 'ArrowDown') { e.preventDefault(); setActive((a) => Math.min(a + 1, items.length - 1)); }
|
||||||
|
else if (e.key === 'ArrowUp') { e.preventDefault(); setActive((a) => Math.max(a - 1, 0)); }
|
||||||
|
else if (e.key === 'Enter') { e.preventDefault(); items[active]?.run(); }
|
||||||
|
else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onClose(); }
|
||||||
|
else if (e.key === 'Tab') { e.preventDefault(); } // the box and the list are one control
|
||||||
|
};
|
||||||
|
|
||||||
|
if (authUser === null && !authServerDown) return null; // the sign-in screen has nothing to search
|
||||||
|
let lastGroup = null;
|
||||||
|
return (
|
||||||
|
<div style={themeVars(readerTheme)} className="fixed inset-0 z-[90] flex items-start justify-center bg-black/40 px-3 pt-[12vh] font-sans" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||||
|
<div role="dialog" aria-modal="true" aria-label="Search and go to" className="w-full max-w-xl overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] text-[var(--r-text)] shadow-2xl">
|
||||||
|
<div className="flex items-center gap-2 border-b border-[var(--r-line)] px-4 py-3">
|
||||||
|
<span className="text-[var(--r-sub)]"><SearchIcon /></span>
|
||||||
|
<input
|
||||||
|
ref={inputRef} value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={onKeyDown}
|
||||||
|
role="combobox" aria-expanded="true" aria-controls="palette-list" aria-autocomplete="list"
|
||||||
|
aria-activedescendant={items[active] ? `palette-item-${active}` : undefined}
|
||||||
|
placeholder="Go to a passage, or search your studies and notes…"
|
||||||
|
className="min-w-0 flex-1 bg-transparent text-base outline-none placeholder:text-[var(--r-sub)]"
|
||||||
|
/>
|
||||||
|
<kbd className="hidden rounded border border-[var(--r-line)] px-1.5 py-0.5 text-[10px] text-[var(--r-sub)] sm:block">esc</kbd>
|
||||||
|
</div>
|
||||||
|
<ul id="palette-list" role="listbox" ref={listRef} className="max-h-[50vh] overflow-y-auto p-2">
|
||||||
|
{items.length === 0 && <li className="px-3 py-6 text-center text-sm text-[var(--r-sub)]">Nothing found. Try a book and chapter, like “romans 8”.</li>}
|
||||||
|
{items.map((item, i) => {
|
||||||
|
const header = item.group !== lastGroup ? item.group : null;
|
||||||
|
lastGroup = item.group;
|
||||||
|
return (
|
||||||
|
<li key={i} role="presentation">
|
||||||
|
{header && <p className="px-3 pb-1 pt-2 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">{header}</p>}
|
||||||
|
<div id={`palette-item-${i}`} role="option" aria-selected={i === active} onMouseMove={() => setActive(i)} onClick={item.run}
|
||||||
|
className={`flex cursor-pointer items-baseline gap-2 rounded-lg px-3 py-2 text-sm ${i === active ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : ''}`}>
|
||||||
|
{item.color && <span className="h-2.5 w-2.5 shrink-0 translate-y-px rounded-full border border-black/10" style={{ backgroundColor: item.color }} />}
|
||||||
|
<span className="shrink-0 font-medium">{item.label}</span>
|
||||||
|
{item.hint && <span className={`min-w-0 truncate text-xs ${i === active ? 'opacity-80' : 'text-[var(--r-sub)]'}`}>{item.hint}</span>}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
<p className="border-t border-[var(--r-line)] px-4 py-2 text-[11px] text-[var(--r-sub)]">↑↓ to move · Enter to open · Esc to close</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// The "Save for offline" control: a button, or progress with a Stop button while a download runs.
|
||||||
|
import { canSaveOffline } from '../utils/offline.js';
|
||||||
|
|
||||||
|
export function DownloadProgress({ job, onCancel }) {
|
||||||
|
const pct = job.total ? Math.round((job.done / job.total) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<div role="status" className="rounded-xl border border-[var(--r-line)] px-3 py-2 text-xs">
|
||||||
|
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||||
|
<span>Saving {job.label}… {job.done} of {job.total}</span>
|
||||||
|
<button type="button" onClick={onCancel} className="text-[var(--r-accent)] hover:underline">Stop</button>
|
||||||
|
</div>
|
||||||
|
<div className="h-1 overflow-hidden rounded-full bg-[var(--r-line)]"><div className="h-full bg-[var(--r-accent)] transition-[width]" style={{ width: `${pct}%` }} /></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One book of a translation.
|
||||||
|
export function SaveBookOffline({ offline, translation, book, name }) {
|
||||||
|
if (!canSaveOffline(translation)) {
|
||||||
|
return <p className="mt-4 text-[11px] text-[var(--r-sub)]">Saving for offline works with the BSB and NET. The {translation}’s licence doesn’t allow it.</p>;
|
||||||
|
}
|
||||||
|
if (offline.job) return <div className="mt-4"><DownloadProgress job={offline.job} onCancel={offline.cancel} /></div>;
|
||||||
|
const saved = offline.isSaved(translation, book);
|
||||||
|
return (
|
||||||
|
<div className="mt-4 flex items-center justify-between gap-3 rounded-xl border border-[var(--r-line)] px-3 py-2 text-sm">
|
||||||
|
<span className={saved ? '' : 'text-[var(--r-sub)]'}>{saved ? `✓ ${name} (${translation}) is saved for offline` : `Read ${name} without a connection`}</span>
|
||||||
|
{!saved && (
|
||||||
|
<button type="button" onClick={() => offline.saveBook(translation, book, name)} className="shrink-0 rounded-full border border-[var(--r-line)] px-3 py-1 text-xs font-medium hover:bg-black/5">Save for offline</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
// Header for an open project (Setup and Study pages): navigation, Claude
|
|
||||||
// prompts, pronunciation guide, export menu, share link, and save/sync status.
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useApp } from '../context/AppContext.js';
|
|
||||||
import { useShareLink } from '../hooks/useShareLink.js';
|
|
||||||
import { buildClaudePrompt, buildPodcastPrompt } from '../utils/exportBuilders.js';
|
|
||||||
import {
|
|
||||||
exportProjectDocx,
|
|
||||||
exportProjectHtml,
|
|
||||||
exportProjectMarkdown,
|
|
||||||
printProjectPdf,
|
|
||||||
} from '../utils/projectExport.js';
|
|
||||||
import AuthStatus from './AuthStatus.jsx';
|
|
||||||
|
|
||||||
// loading: true while the Setup page is fetching a chapter.
|
|
||||||
export default function ProjectHeader({ loading = false }) {
|
|
||||||
const {
|
|
||||||
authUser,
|
|
||||||
currentPage, setCurrentPage,
|
|
||||||
goHome,
|
|
||||||
project,
|
|
||||||
allChunks,
|
|
||||||
saveStatus, setSaveStatus,
|
|
||||||
syncStatus,
|
|
||||||
retrySync,
|
|
||||||
} = useApp();
|
|
||||||
const [exportMenuOpen, setExportMenuOpen] = useState(false);
|
|
||||||
const [sharePanelOpen, setSharePanelOpen] = useState(false);
|
|
||||||
const share = useShareLink(project?.id);
|
|
||||||
|
|
||||||
// Copies a prompt, then opens claude.ai so it can be pasted straight in.
|
|
||||||
const copyPrompt = (prompt) => {
|
|
||||||
navigator.clipboard.writeText(prompt).then(() => {
|
|
||||||
setSaveStatus('Copied! Opening Claude…');
|
|
||||||
window.setTimeout(() => setSaveStatus(''), 3000);
|
|
||||||
window.open('https://claude.ai/new', '_blank', 'noopener,noreferrer');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
{currentPage !== 'home' && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={goHome}
|
|
||||||
className="rounded-xl border border-white/15 bg-white/10 px-4 py-2 text-sm text-white transition hover:bg-white/15"
|
|
||||||
>
|
|
||||||
← Home
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{project && currentPage === 'study' && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setCurrentPage('setup')}
|
|
||||||
className="rounded-xl border border-white/15 bg-white/10 px-4 py-2 text-sm text-white transition hover:bg-white/15"
|
|
||||||
>
|
|
||||||
← Back to Chunks
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{project && (
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => copyPrompt(buildClaudePrompt(project))}
|
|
||||||
disabled={allChunks.length === 0}
|
|
||||||
className="rounded-md bg-violet-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-violet-400 disabled:cursor-not-allowed disabled:bg-slate-500"
|
|
||||||
title="Copies your study notes as a Claude prompt, then opens claude.ai so you can paste and go"
|
|
||||||
>
|
|
||||||
<span className="hidden sm:inline">Study Guide → </span>Claude ↗
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => copyPrompt(buildPodcastPrompt(project, authUser?.podcastName))}
|
|
||||||
disabled={allChunks.length === 0}
|
|
||||||
title="Copies a podcast script prompt for Claude, then opens claude.ai — set your show name in Account Settings first"
|
|
||||||
className="rounded-md bg-fuchsia-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-fuchsia-400 disabled:cursor-not-allowed disabled:bg-slate-500"
|
|
||||||
>
|
|
||||||
🎙<span className="hidden sm:inline"> Podcast → Claude ↗</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setCurrentPage('pronunciation')}
|
|
||||||
disabled={allChunks.length === 0}
|
|
||||||
title="Open the pronunciation guide — all Greek & Hebrew words with transliterations"
|
|
||||||
className="rounded-md bg-amber-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:bg-slate-500"
|
|
||||||
>
|
|
||||||
🗣<span className="hidden sm:inline"> Pronunciation Guide</span>
|
|
||||||
</button>
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setExportMenuOpen((v) => !v)}
|
|
||||||
className="flex items-center gap-1.5 rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400"
|
|
||||||
>
|
|
||||||
⬇ Export
|
|
||||||
</button>
|
|
||||||
{exportMenuOpen && (
|
|
||||||
<div className="absolute left-0 top-full z-20 mt-2 w-56 overflow-hidden rounded-2xl border border-slate-200 bg-white py-1.5 text-left shadow-lg">
|
|
||||||
<button type="button" onClick={() => { exportProjectHtml(project); setExportMenuOpen(false); }}
|
|
||||||
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50">
|
|
||||||
Export HTML
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={() => { exportProjectDocx(project); setExportMenuOpen(false); }}
|
|
||||||
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50">
|
|
||||||
Export DOCX
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={() => { exportProjectMarkdown(project); setExportMenuOpen(false); }}
|
|
||||||
title="Handy for Obsidian, Notion, or any Markdown-based notes app"
|
|
||||||
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50">
|
|
||||||
Export Markdown
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={() => { printProjectPdf(project); setExportMenuOpen(false); }}
|
|
||||||
title="Opens a print-friendly version in a new tab — choose 'Save as PDF' in the print dialog"
|
|
||||||
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50">
|
|
||||||
🖨 Print / Save PDF
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSharePanelOpen((v) => !v)}
|
|
||||||
className={`rounded-md px-4 py-2 text-sm font-medium shadow-sm transition ${share.shareToken ? 'bg-teal-600 text-white hover:bg-teal-500' : 'border border-slate-300 bg-white text-slate-700 hover:bg-slate-50'}`}
|
|
||||||
>
|
|
||||||
🔗 {share.shareToken ? 'Shared' : 'Share'}
|
|
||||||
</button>
|
|
||||||
{sharePanelOpen && (
|
|
||||||
<div className="absolute right-0 top-full z-20 mt-2 w-80 rounded-2xl border border-slate-200 bg-white p-4 text-left shadow-lg">
|
|
||||||
<h3 className="text-sm font-semibold text-slate-900">Read-only share link</h3>
|
|
||||||
<p className="mt-1 text-xs text-slate-500">
|
|
||||||
Anyone with this link can view (not edit) this study — no account needed. Good for a co-teacher
|
|
||||||
or a listener who wants to follow along.
|
|
||||||
</p>
|
|
||||||
{share.shareToken ? (
|
|
||||||
<div className="mt-3 space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
readOnly
|
|
||||||
value={share.shareUrl}
|
|
||||||
onFocus={(e) => e.target.select()}
|
|
||||||
className="w-full flex-1 truncate rounded-lg border border-slate-300 bg-slate-50 px-2 py-1.5 text-xs text-slate-600"
|
|
||||||
/>
|
|
||||||
<button type="button" onClick={share.copy}
|
|
||||||
className="shrink-0 rounded-lg bg-sky-500 px-3 py-1.5 text-xs font-semibold text-white hover:bg-sky-400">
|
|
||||||
{share.shareCopied ? 'Copied!' : 'Copy'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button type="button" onClick={share.revoke} disabled={share.shareBusy}
|
|
||||||
className="w-full rounded-lg border border-rose-200 px-3 py-1.5 text-xs font-semibold text-rose-600 hover:bg-rose-50 disabled:cursor-not-allowed disabled:opacity-50">
|
|
||||||
{share.shareBusy ? 'Revoking…' : 'Revoke link'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button type="button" onClick={share.enable} disabled={share.shareBusy}
|
|
||||||
className="mt-3 w-full rounded-lg bg-teal-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-teal-500 disabled:cursor-not-allowed disabled:opacity-50">
|
|
||||||
{share.shareBusy ? 'Enabling…' : 'Enable sharing'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="text-right text-sm text-slate-300 space-y-0.5">
|
|
||||||
{loading ? (
|
|
||||||
<span>Loading…</span>
|
|
||||||
) : saveStatus ? (
|
|
||||||
<span className="text-emerald-300">{saveStatus}</span>
|
|
||||||
) : (
|
|
||||||
<span> </span>
|
|
||||||
)}
|
|
||||||
{syncStatus === 'syncing' && <div className="text-xs text-slate-400">Syncing…</div>}
|
|
||||||
{syncStatus === 'synced' && <div className="text-xs text-emerald-400">Synced ✓</div>}
|
|
||||||
{syncStatus === 'error' && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs text-rose-400">⚠ Sync failed</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={retrySync}
|
|
||||||
className="text-xs text-slate-300 underline hover:text-white"
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<AuthStatus />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Share an outline: a read-only link anyone can open (no account), or the plain text.
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Sheet from './reader/Sheet.jsx';
|
||||||
|
import { useToast } from './Toasts.jsx';
|
||||||
|
import { useOutlineShareLink } from '../hooks/useOutlineShareLink.js';
|
||||||
|
import { shareOutline } from '../utils/outlineExport.js';
|
||||||
|
|
||||||
|
const btn = 'rounded-xl border border-[var(--r-line)] px-3.5 py-2 text-sm hover:bg-black/5 disabled:opacity-50';
|
||||||
|
|
||||||
|
export default function ShareOutlineSheet({ open, onClose, projectId, outline }) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const link = useOutlineShareLink(projectId, open);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const copyLink = async () => {
|
||||||
|
try { await navigator.clipboard.writeText(link.url); setCopied(true); window.setTimeout(() => setCopied(false), 2000); } catch { toast('Couldn’t copy. Select the link and copy it.', { tone: 'error' }); }
|
||||||
|
};
|
||||||
|
// The phone/computer share sheet, with the link (falls back to copying it)
|
||||||
|
const shareLink = async () => {
|
||||||
|
if (navigator.share) {
|
||||||
|
try { await navigator.share({ title: `${outline.title} — Outline`, url: link.url }); return; } catch (err) { if (err?.name === 'AbortError') return; }
|
||||||
|
}
|
||||||
|
copyLink();
|
||||||
|
};
|
||||||
|
const shareText = async () => {
|
||||||
|
const result = await shareOutline(outline);
|
||||||
|
if (result === 'copied') toast('Outline copied. Paste it into a message or email.');
|
||||||
|
else if (result === 'failed') toast('Couldn’t share or copy the outline.', { tone: 'error' });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onClose={onClose} title="Share this outline">
|
||||||
|
<h3 className="text-sm font-medium">Read-only link</h3>
|
||||||
|
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">
|
||||||
|
Anyone with the link can read this study’s outlines (the whole-study one and each chunk’s), and print them. They can’t edit anything, and they can’t see your notes or scripts. No account needed.
|
||||||
|
</p>
|
||||||
|
{link.state === 'loading' && <p className="text-sm text-[var(--r-sub)]">Checking…</p>}
|
||||||
|
{link.state === 'unavailable' && (
|
||||||
|
<p className="rounded-xl border border-[var(--r-line)] px-3 py-2 text-sm text-[var(--r-sub)]">Links need your account and a connection to the server. You can still share the text below.</p>
|
||||||
|
)}
|
||||||
|
{link.state === 'ready' && (link.token ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input readOnly aria-label="Outline link" value={link.url} onFocus={(e) => e.target.select()}
|
||||||
|
className="min-w-0 flex-1 truncate rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-xs text-[var(--r-sub)]" />
|
||||||
|
<button type="button" onClick={copyLink} className="shrink-0 rounded-lg bg-[var(--r-accent)] px-4 py-2 text-xs font-medium text-[var(--r-on)]">{copied ? 'Copied!' : 'Copy'}</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<button type="button" onClick={shareLink} className={btn}>Share link…</button>
|
||||||
|
<button type="button" onClick={link.turnOff} disabled={link.busy} className="text-xs text-[var(--r-danger,#b42318)] hover:underline">{link.busy ? 'Turning off…' : 'Turn off link'}</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-[var(--r-sub)]">People see the version that last synced to your account, and it updates as you edit. Turning the link off stops it working right away.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button type="button" onClick={link.create} disabled={link.busy} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50">{link.busy ? 'Creating…' : 'Create read-only link'}</button>
|
||||||
|
))}
|
||||||
|
{link.error && <p role="alert" className="mt-2 text-xs text-[var(--r-danger,#b42318)]">{link.error}</p>}
|
||||||
|
|
||||||
|
<div className="mt-5 border-t border-[var(--r-line)] pt-4">
|
||||||
|
<h3 className="text-sm font-medium">Or send the text</h3>
|
||||||
|
<p className="mb-2 mt-1 text-xs text-[var(--r-sub)]">The outline you’re looking at, as plain text, to paste into a message or email.</p>
|
||||||
|
<button type="button" onClick={shareText} className={btn}>Share as text</button>
|
||||||
|
</div>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// What someone sees when they open a read-only outline link: a calm page with the outline, a switcher
|
||||||
|
// when there is more than one, and Print / PDF. Nothing is editable.
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { getSharedOutline } from '../syncService.js';
|
||||||
|
import { themeVars } from './reader/readerThemes.js';
|
||||||
|
import OutlineDocument from './OutlineDocument.jsx';
|
||||||
|
import { printOutline } from '../utils/outlineExport.js';
|
||||||
|
import { flatten } from '../utils/outline.js';
|
||||||
|
|
||||||
|
const pill = (active) => `shrink-0 rounded-full px-3.5 py-1.5 text-sm transition ${active ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`;
|
||||||
|
|
||||||
|
function Frame({ children }) {
|
||||||
|
return <div className="min-h-dvh bg-[var(--r-bg)] font-sans text-[var(--r-text)]" style={themeVars('paper')}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SharedOutlineView({ token }) {
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [scope, setScope] = useState('study');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
getSharedOutline(token).then((result) => {
|
||||||
|
if (!active) return;
|
||||||
|
if (result.ok) { setData(result.data); setScope(result.data.outlines[0]?.scope ?? 'study'); } else setError(result.error ?? 'This outline link is invalid or has been turned off.');
|
||||||
|
});
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
useEffect(() => { if (data?.title) document.title = `${data.title} — Outline`; }, [data]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Frame>
|
||||||
|
<div className="flex min-h-dvh items-center justify-center px-5">
|
||||||
|
<div className="max-w-sm text-center">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--r-accent)]">Bible Study Project</p>
|
||||||
|
<p className="mt-4 text-base">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Frame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!data) return <Frame><p className="flex min-h-dvh items-center justify-center text-sm text-[var(--r-sub)]">Loading outline…</p></Frame>;
|
||||||
|
|
||||||
|
const current = data.outlines.find((o) => o.scope === scope) ?? data.outlines[0];
|
||||||
|
const title = current && current.scope !== 'study' ? `${data.title} — ${current.label}` : data.title;
|
||||||
|
const minutes = current ? flatten(current.tree).reduce((sum, { node }) => sum + (Number.isFinite(node.minutes) ? node.minutes : 0), 0) : 0;
|
||||||
|
const meta = current ? [current.scope === 'study' ? data.translation : `${current.label} · ${data.translation}`, minutes > 0 ? `About ${minutes} minute${minutes === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · ') : '';
|
||||||
|
const outline = current ? { title, subtitle: current.scope === 'study' ? data.translation : `${current.label} · ${data.translation}`, tree: current.tree } : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Frame>
|
||||||
|
<header className="sticky top-0 z-10 border-b border-[var(--r-line)] bg-[var(--r-bar)]/95 backdrop-blur">
|
||||||
|
<div className="mx-auto flex max-w-[44rem] flex-wrap items-center gap-x-3 gap-y-2 px-5 py-2.5 sm:px-8">
|
||||||
|
<span className="shrink-0 text-sm font-medium">Bible Study</span>
|
||||||
|
{data.outlines.length > 1 ? (
|
||||||
|
<nav aria-label="Outline" className="order-last flex w-full min-w-0 gap-1 overflow-x-auto sm:order-none sm:w-auto sm:flex-1">
|
||||||
|
{data.outlines.map((o) => (
|
||||||
|
<button key={o.scope} type="button" onClick={() => setScope(o.scope)} aria-current={current?.scope === o.scope ? 'true' : undefined} className={pill(current?.scope === o.scope)}>{o.label}</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
) : <span className="flex-1" />}
|
||||||
|
{outline && <button type="button" onClick={() => printOutline(outline)} className="ml-auto shrink-0 sm:ml-0 rounded-full border border-[var(--r-line)] px-3.5 py-1.5 text-sm hover:bg-black/5">Print / PDF</button>}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{current ? <OutlineDocument title={title} meta={meta} tree={current.tree} /> : <p className="p-10 text-center text-sm text-[var(--r-sub)]">This study doesn’t have an outline yet.</p>}
|
||||||
|
</Frame>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// The "?" cheat sheet: every keyboard shortcut for the page you are on, plus the ones that work everywhere.
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useApp } from '../context/AppContext.js';
|
||||||
|
import { themeVars } from './reader/readerThemes.js';
|
||||||
|
|
||||||
|
const GLOBAL = [
|
||||||
|
['⌘K / Ctrl K', 'Search and go to a passage'],
|
||||||
|
['?', 'Show this list'],
|
||||||
|
['Esc', 'Close whatever is open'],
|
||||||
|
];
|
||||||
|
const BY_PAGE = {
|
||||||
|
reader: [
|
||||||
|
['← →', 'Previous / next chapter'],
|
||||||
|
['/', 'Search this chapter or the whole Bible'],
|
||||||
|
['G', 'Pick a book and chapter'],
|
||||||
|
['B', 'Bookmark the selected verse'],
|
||||||
|
['C', 'Compare translations'],
|
||||||
|
[',', 'Reading settings'],
|
||||||
|
['F', 'Hide or show the controls'],
|
||||||
|
],
|
||||||
|
study: [
|
||||||
|
['← →', 'Previous / next chunk'],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ShortcutsHelp({ onClose }) {
|
||||||
|
const { readerTheme, currentPage } = useApp();
|
||||||
|
const closeRef = useRef(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const from = document.activeElement;
|
||||||
|
closeRef.current?.focus();
|
||||||
|
const onKey = (e) => { if (e.key === 'Escape' || e.key === '?') { e.preventDefault(); e.stopPropagation(); onClose(); } };
|
||||||
|
document.addEventListener('keydown', onKey, true);
|
||||||
|
return () => { document.removeEventListener('keydown', onKey, true); if (from instanceof HTMLElement && document.contains(from)) from.focus({ preventScroll: true }); };
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const page = BY_PAGE[currentPage] ?? [];
|
||||||
|
const section = (title, rows) => (
|
||||||
|
<div className="mb-4 last:mb-0">
|
||||||
|
<h3 className="mb-1.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">{title}</h3>
|
||||||
|
<dl className="space-y-1.5">
|
||||||
|
{rows.map(([keys, what]) => (
|
||||||
|
<div key={keys} className="flex items-center justify-between gap-4 text-sm">
|
||||||
|
<dt><kbd className="rounded-md border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-0.5 font-mono text-xs">{keys}</kbd></dt>
|
||||||
|
<dd className="text-right text-[var(--r-sub)]">{what}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={themeVars(readerTheme)} className="fixed inset-0 z-[90] flex items-center justify-center bg-black/40 px-3 font-sans" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||||
|
<div role="dialog" aria-modal="true" aria-label="Keyboard shortcuts" className="max-h-[80vh] w-full max-w-sm overflow-y-auto rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5 text-[var(--r-text)] shadow-2xl">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h2 className="text-base font-medium">Keyboard shortcuts</h2>
|
||||||
|
<button ref={closeRef} type="button" onClick={onClose} className="rounded-lg px-2 py-1 text-sm text-[var(--r-sub)] hover:bg-black/5">Close</button>
|
||||||
|
</div>
|
||||||
|
{page.length > 0 && section(currentPage === 'reader' ? 'In the Bible' : 'In this study', page)}
|
||||||
|
{section('Everywhere', GLOBAL)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// Inline study panel shown under a verse in the Reader: commentary from several
|
||||||
|
// public-domain / open sources, plus the people, places and events tied to the verse.
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useApp } from '../context/AppContext.js';
|
||||||
|
import {
|
||||||
|
COMMENTARIES, loadCommentary, loadTheographic, loadPerson, pickCommentaryEntry, useAsync,
|
||||||
|
} from '../hooks/useVerseStudy.js';
|
||||||
|
|
||||||
|
export function CommentaryTab({ book, chapter, verse }) {
|
||||||
|
// The chosen commentary is a synced preference, shared with the Study stage
|
||||||
|
const { readerCommentary: sourceId, setReaderCommentary: changeSource } = useApp();
|
||||||
|
const source = COMMENTARIES.find((c) => c.id === sourceId) ?? COMMENTARIES[0];
|
||||||
|
const { status, data } = useAsync(() => loadCommentary(source.id, book, chapter), [source.id, book, chapter]);
|
||||||
|
const entry = pickCommentaryEntry(data, verse);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-[var(--r-sub,#64748b)]">
|
||||||
|
Commentary{' '}
|
||||||
|
<select value={source.id} onChange={(e) => changeSource(e.target.value)}
|
||||||
|
className="rounded-lg border border-[var(--r-line,#cbd5e1)] bg-[var(--r-bg,#ffffff)] px-2 py-1 text-xs">
|
||||||
|
{COMMENTARIES.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="mt-2">
|
||||||
|
{status === 'loading' && <p className="text-xs text-[var(--r-sub,#64748b)]">Loading…</p>}
|
||||||
|
{status === 'empty' && <p className="text-xs text-[var(--r-sub,#64748b)]">{source.name} has no notes for this chapter.</p>}
|
||||||
|
{status === 'ready' && !entry && <p className="text-xs text-[var(--r-sub,#64748b)]">No notes at or before this verse in {source.name}.</p>}
|
||||||
|
{status === 'ready' && entry && (
|
||||||
|
<>
|
||||||
|
{entry.number !== verse && (
|
||||||
|
<p className="mb-1 text-[11px] text-[var(--r-sub,#64748b)]">Notes on the passage starting at verse {entry.number}.</p>
|
||||||
|
)}
|
||||||
|
<div className="max-h-96 overflow-y-auto whitespace-pre-line rounded-xl bg-[var(--r-bg,#ffffff)] p-3 text-[13px] leading-relaxed text-[var(--r-text,#334155)]">
|
||||||
|
{entry.text}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-[10px] text-[var(--r-sub,#64748b)]">{source.credit}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PersonDetails({ id }) {
|
||||||
|
const { status, data } = useAsync(() => loadPerson(id), [id]);
|
||||||
|
if (status === 'loading') return <p className="mt-1 text-xs text-[var(--r-sub,#64748b)]">Loading…</p>;
|
||||||
|
if (status === 'empty') return <p className="mt-1 text-xs text-[var(--r-sub,#64748b)]">No details available.</p>;
|
||||||
|
const era = (y) => `${Math.abs(y)}${y < 0 ? ' BC' : ' AD'}`;
|
||||||
|
return (
|
||||||
|
<div className="mt-1 space-y-1 text-xs leading-relaxed text-[var(--r-sub,#64748b)]">
|
||||||
|
{(data.description ?? []).map((d, i) => <p key={i}>{d}</p>)}
|
||||||
|
{!data.description?.length && <p>No description available.</p>}
|
||||||
|
{data.minYear != null && (
|
||||||
|
<p className="text-[var(--r-sub,#64748b)]">
|
||||||
|
Lived approx. {era(data.minYear)}{data.maxYear != null && data.maxYear !== data.minYear ? ` – ${era(data.maxYear)}` : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PersonCard({ person }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<li className="rounded-xl bg-[var(--r-bg,#ffffff)] px-3 py-2">
|
||||||
|
<button type="button" onClick={() => setOpen((o) => !o)} className="w-full text-left text-sm font-semibold text-[var(--r-text,#334155)]">
|
||||||
|
{person.name} <span className="text-xs font-normal text-[var(--r-sub,#64748b)]">{open ? '▾' : '▸'}</span>
|
||||||
|
</button>
|
||||||
|
{open && <PersonDetails id={person.id} />}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PeoplePlacesTab({ book, chapter, verse }) {
|
||||||
|
const { status, data } = useAsync(() => loadTheographic(book, chapter), [book, chapter]);
|
||||||
|
if (status === 'loading') return <p className="text-xs text-[var(--r-sub,#64748b)]">Loading…</p>;
|
||||||
|
if (status === 'empty') return <p className="text-xs text-[var(--r-sub,#64748b)]">No people or places data for this chapter.</p>;
|
||||||
|
const here = (list) => list.filter((x) => x.verses?.includes(verse));
|
||||||
|
// Theographic lists some places twice (variant spellings) — collapse by name.
|
||||||
|
const places = [...new Map(here(data.places).map((p) => [p.name, p])).values()];
|
||||||
|
const people = here(data.people);
|
||||||
|
const events = here(data.events);
|
||||||
|
if (!people.length && !places.length && !events.length) {
|
||||||
|
return <p className="text-xs text-[var(--r-sub,#64748b)]">Nothing tagged on verse {verse}.</p>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{people.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-[var(--r-sub,#64748b)]">People</p>
|
||||||
|
<ul className="space-y-1">{people.map((p) => <PersonCard key={p.id} person={p} />)}</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{places.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-[var(--r-sub,#64748b)]">Places</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{places.map((p) => (
|
||||||
|
<li key={p.id} className="flex items-center justify-between gap-2 rounded-xl bg-[var(--r-bg,#ffffff)] px-3 py-2 text-sm text-[var(--r-text,#334155)]">
|
||||||
|
<span>{p.name}{p.featureType && <span className="ml-1 text-xs text-[var(--r-sub,#64748b)]">{p.featureType}</span>}</span>
|
||||||
|
{p.latitude != null && p.longitude != null && (
|
||||||
|
<a href={`https://www.openstreetmap.org/?mlat=${p.latitude}&mlon=${p.longitude}#map=8/${p.latitude}/${p.longitude}`}
|
||||||
|
target="_blank" rel="noopener noreferrer" className="text-xs font-semibold text-sky-600 hover:underline">
|
||||||
|
Map ↗
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{events.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-[var(--r-sub,#64748b)]">Events</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{events.map((e) => (
|
||||||
|
<li key={e.id} className="rounded-xl bg-[var(--r-bg,#ffffff)] px-3 py-2 text-sm text-[var(--r-text,#334155)]">
|
||||||
|
{e.name}{e.startDate && <span className="ml-1 text-xs text-[var(--r-sub,#64748b)]">approx. AD {e.startDate}</span>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-[10px] text-[var(--r-sub,#64748b)]">
|
||||||
|
People, places and events: Theographic Bible Metadata, licensed CC BY-SA 4.0.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StudyPanel({ book, chapter, verse, onClose }) {
|
||||||
|
const [tab, setTab] = useState('commentary');
|
||||||
|
const tabBtn = (id, label) => (
|
||||||
|
<button type="button" onClick={() => setTab(id)}
|
||||||
|
className={`rounded-lg px-3 py-1 text-xs font-semibold ${tab === id ? 'bg-slate-900 text-white' : 'bg-[var(--r-bg,#ffffff)] text-[var(--r-sub,#64748b)] hover:bg-slate-100'}`}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="mt-2 mb-1 ml-6 rounded-2xl border border-indigo-100 bg-indigo-50 p-3">
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{tabBtn('commentary', 'Commentary')}
|
||||||
|
{tabBtn('people', 'People & Places')}
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={onClose} className="rounded p-1 text-xs text-[var(--r-sub,#64748b)] hover:bg-[var(--r-bg,#ffffff)]" title="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
{tab === 'commentary'
|
||||||
|
? <CommentaryTab book={book} chapter={chapter} verse={verse} />
|
||||||
|
: <PeoplePlacesTab book={book} chapter={chapter} verse={verse} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// App-wide toasts: short messages at the bottom of the screen, optionally with one
|
||||||
|
// action button (usually "Undo"). Used instead of alert()/confirm() so a slip is
|
||||||
|
// reversible and nothing blocks the page.
|
||||||
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { themeVars } from './reader/readerThemes.js';
|
||||||
|
|
||||||
|
const ToastContext = createContext(null);
|
||||||
|
|
||||||
|
// toast(message, { action: { label, onClick }, ms, tone: 'info' | 'error' }) -> id
|
||||||
|
// Outside a provider (a page rendered on its own in a test) it quietly does nothing.
|
||||||
|
const NOOP = { toast: () => 0, dismiss: () => {}, setTheme: () => {} };
|
||||||
|
export const useToast = () => useContext(ToastContext) ?? NOOP;
|
||||||
|
|
||||||
|
let nextId = 1;
|
||||||
|
|
||||||
|
export function ToastProvider({ children }) {
|
||||||
|
const [toasts, setToasts] = useState([]);
|
||||||
|
const [theme, setTheme] = useState('paper'); // set by the app so toasts match the reading theme
|
||||||
|
const timers = useRef(new Map());
|
||||||
|
|
||||||
|
const dismiss = useCallback((id) => {
|
||||||
|
window.clearTimeout(timers.current.get(id));
|
||||||
|
timers.current.delete(id);
|
||||||
|
setToasts((list) => list.filter((t) => t.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toast = useCallback((message, { action, ms, tone = 'info' } = {}) => {
|
||||||
|
const id = nextId++;
|
||||||
|
// Messages with an action stay long enough to read and react to.
|
||||||
|
const duration = ms ?? (action ? 8000 : tone === 'error' ? 6000 : 3500);
|
||||||
|
setToasts((list) => [...list.slice(-2), { id, message, action, tone }]);
|
||||||
|
timers.current.set(id, window.setTimeout(() => dismiss(id), duration));
|
||||||
|
return id;
|
||||||
|
}, [dismiss]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const live = timers.current;
|
||||||
|
return () => live.forEach((t) => window.clearTimeout(t));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo(() => ({ toast, dismiss, setTheme }), [toast, dismiss]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
<div style={themeVars(theme)} className="pointer-events-none fixed inset-x-0 bottom-0 z-[100] flex flex-col items-center gap-2 px-4 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] font-sans">
|
||||||
|
{toasts.map((t) => (
|
||||||
|
<div key={t.id} role={t.tone === 'error' ? 'alert' : 'status'}
|
||||||
|
className={`pointer-events-auto flex max-w-md items-center gap-3 rounded-full px-4 py-2.5 text-sm shadow-lg ${t.tone === 'error' ? 'bg-[var(--r-danger)] text-[var(--r-bg)]' : 'bg-[var(--r-text)] text-[var(--r-bg)]'}`}>
|
||||||
|
<span>{t.message}</span>
|
||||||
|
{t.action && (
|
||||||
|
<button type="button" onClick={() => { t.action.onClick(); dismiss(t.id); }}
|
||||||
|
className="shrink-0 rounded-full px-2 py-0.5 text-sm font-semibold underline underline-offset-2 hover:opacity-80">
|
||||||
|
{t.action.label}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
// Form for creating or editing a custom reading plan (title, days, cover photo,
|
||||||
|
// books and chapter ranges). Themed with the reader's --r-* colour variables.
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { bookOptions, CHAPTER_COUNTS } from '../../utils/bibleUtils.jsx';
|
||||||
|
import {
|
||||||
|
bookChapterCount, clampPlanDays, ALL_BOOKS, OT_BOOKS, NT_BOOKS, GOSPELS, PENTATEUCH, WISDOM_BOOKS,
|
||||||
|
} from '../../utils/readingPlans.js';
|
||||||
|
|
||||||
|
// Downscales a picked image file to a reasonable cover-photo size and returns
|
||||||
|
// a data URL — no server upload endpoint needed, it's stored right on the plan.
|
||||||
|
export function resizeImageToDataUrl(file, maxWidth = 960, quality = 0.82) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onerror = () => reject(new Error('Could not read file.'));
|
||||||
|
reader.onload = () => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onerror = () => reject(new Error('Could not decode image.'));
|
||||||
|
img.onload = () => {
|
||||||
|
const scale = Math.min(1, maxWidth / img.width);
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = Math.round(img.width * scale);
|
||||||
|
canvas.height = Math.round(img.height * scale);
|
||||||
|
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||||
|
resolve(canvas.toDataURL('image/jpeg', quality));
|
||||||
|
};
|
||||||
|
img.src = reader.result;
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const BOOK_PRESETS = [
|
||||||
|
['All', ALL_BOOKS],
|
||||||
|
['None', []],
|
||||||
|
['Old Testament', OT_BOOKS],
|
||||||
|
['New Testament', NT_BOOKS],
|
||||||
|
['Gospels', GOSPELS],
|
||||||
|
['Pentateuch', PENTATEUCH],
|
||||||
|
['Psalms', ['PSA']],
|
||||||
|
['Proverbs', ['PRO']],
|
||||||
|
['Wisdom', WISDOM_BOOKS],
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function CustomPlanBuilder({ initialValues, onSubmit, onCancel }) {
|
||||||
|
const [title, setTitle] = useState(initialValues?.title ?? '');
|
||||||
|
const [description, setDescription] = useState(initialValues?.description ?? '');
|
||||||
|
const [days, setDays] = useState(initialValues?.days ?? 30);
|
||||||
|
const [books, setBooks] = useState(initialValues?.books ?? []);
|
||||||
|
const [chapterRanges, setChapterRanges] = useState(initialValues?.chapterRanges ?? {});
|
||||||
|
const [image, setImage] = useState(initialValues?.image ?? null);
|
||||||
|
const [imageError, setImageError] = useState('');
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
const isEditing = !!initialValues;
|
||||||
|
|
||||||
|
const toggleBook = (abbrev) => {
|
||||||
|
const isOn = books.includes(abbrev);
|
||||||
|
setBooks((prev) => (isOn ? prev.filter((b) => b !== abbrev) : [...prev, abbrev]));
|
||||||
|
if (isOn) {
|
||||||
|
setChapterRanges((prev) => {
|
||||||
|
if (!(abbrev in prev)) return prev;
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[abbrev];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setChapterRange = (abbrev, key, value) => {
|
||||||
|
const total = CHAPTER_COUNTS[abbrev] ?? 1;
|
||||||
|
const clamped = Math.min(total, Math.max(1, Math.round(value) || 1));
|
||||||
|
setChapterRanges((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[abbrev]: { start: prev[abbrev]?.start ?? 1, end: prev[abbrev]?.end ?? total, [key]: clamped },
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyPreset = (list) => {
|
||||||
|
setBooks(list);
|
||||||
|
setChapterRanges({});
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickImage = async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = '';
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
setImageError('');
|
||||||
|
setImage(await resizeImageToDataUrl(file));
|
||||||
|
} catch {
|
||||||
|
setImageError("Couldn't use that image.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalChapters = books.reduce((sum, b) => sum + bookChapterCount(b, chapterRanges), 0);
|
||||||
|
const canCreate = title.trim().length > 0 && totalChapters > 0 && days >= 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5">
|
||||||
|
<h3 className="mb-3 text-sm font-semibold text-[var(--r-text)]">{isEditing ? 'Edit reading plan' : 'New reading plan'}</h3>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<label className="text-sm text-[var(--r-text)]">
|
||||||
|
Title
|
||||||
|
<input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="e.g. Advent Readings"
|
||||||
|
className="mt-1 w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm text-[var(--r-text)]">
|
||||||
|
Days
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="365"
|
||||||
|
value={days}
|
||||||
|
onChange={(e) => setDays(Number(e.target.value))}
|
||||||
|
className="mt-1 w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="mt-3 block text-sm text-[var(--r-text)]">
|
||||||
|
Description <span className="text-[var(--r-sub)]">(optional)</span>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="mt-1 w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
<span className="text-sm text-[var(--r-text)]">Cover photo <span className="text-[var(--r-sub)]">(optional)</span></span>
|
||||||
|
<div className="mt-1 flex items-center gap-3">
|
||||||
|
{image ? (
|
||||||
|
<img src={image} alt="" className="h-16 w-28 rounded-lg object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-16 w-28 items-center justify-center rounded-lg bg-[var(--r-bg)] text-2xl">🖼️</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] px-3 py-1.5 text-sm font-semibold text-[var(--r-text)] hover:bg-black/5"
|
||||||
|
>
|
||||||
|
{image ? 'Change photo' : 'Choose photo'}
|
||||||
|
</button>
|
||||||
|
{image && (
|
||||||
|
<button type="button" onClick={() => setImage(null)} className="text-sm text-[var(--r-sub)] hover:text-rose-500">
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<input ref={fileInputRef} type="file" accept="image/*" onChange={pickImage} className="hidden" />
|
||||||
|
</div>
|
||||||
|
{imageError && <p className="mt-1 text-xs text-rose-500">{imageError}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold text-[var(--r-text)]">Books</span>
|
||||||
|
{BOOK_PRESETS.map(([label, list]) => (
|
||||||
|
<button
|
||||||
|
key={label}
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyPreset(list)}
|
||||||
|
className="rounded-full border border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] px-2.5 py-1 text-xs font-semibold text-[var(--r-text)] hover:bg-black/5"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mb-2 text-xs text-[var(--r-sub)]">
|
||||||
|
Check a book to include the whole thing, or narrow it to a chapter range with the fields that appear.
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="max-h-56 overflow-y-auto rounded-xl border border-[var(--r-line)] p-2">
|
||||||
|
<p className="mb-1 px-1 text-xs font-semibold uppercase tracking-wide text-[var(--r-sub)]">Old Testament</p>
|
||||||
|
{bookOptions.filter((b) => OT_BOOKS.includes(b.abbrev)).map((b) => (
|
||||||
|
<BookRow
|
||||||
|
key={b.abbrev}
|
||||||
|
book={b}
|
||||||
|
checked={books.includes(b.abbrev)}
|
||||||
|
range={chapterRanges[b.abbrev]}
|
||||||
|
onToggle={() => toggleBook(b.abbrev)}
|
||||||
|
onRangeChange={(key, value) => setChapterRange(b.abbrev, key, value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="max-h-56 overflow-y-auto rounded-xl border border-[var(--r-line)] p-2">
|
||||||
|
<p className="mb-1 px-1 text-xs font-semibold uppercase tracking-wide text-[var(--r-sub)]">New Testament</p>
|
||||||
|
{bookOptions.filter((b) => NT_BOOKS.includes(b.abbrev)).map((b) => (
|
||||||
|
<BookRow
|
||||||
|
key={b.abbrev}
|
||||||
|
book={b}
|
||||||
|
checked={books.includes(b.abbrev)}
|
||||||
|
range={chapterRanges[b.abbrev]}
|
||||||
|
onToggle={() => toggleBook(b.abbrev)}
|
||||||
|
onRangeChange={(key, value) => setChapterRange(b.abbrev, key, value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-[var(--r-sub)]">
|
||||||
|
{books.length} book{books.length !== 1 ? 's' : ''} selected · {totalChapters} chapters
|
||||||
|
{days > 0 && totalChapters > 0 && ` · ~${(totalChapters / Math.min(days, totalChapters)).toFixed(1)} ch/day`}
|
||||||
|
</p>
|
||||||
|
{totalChapters > 0 && Number(days) > totalChapters && (
|
||||||
|
<p className="mt-1 text-xs text-[var(--r-accent)]">
|
||||||
|
That’s more days than chapters, so the plan will be {totalChapters} days, one chapter a day.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{isEditing && (
|
||||||
|
books.join() !== initialValues.books.join()
|
||||||
|
|| Number(days) !== initialValues.days
|
||||||
|
|| JSON.stringify(chapterRanges) !== JSON.stringify(initialValues.chapterRanges ?? {})
|
||||||
|
) && (
|
||||||
|
<p className="mt-2 text-xs text-amber-600">
|
||||||
|
Changing the books, chapters, or day count restarts progress for any run of this plan already in your list.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canCreate}
|
||||||
|
onClick={() => onSubmit({ title, description, days: clampPlanDays(books, chapterRanges, days), books, chapterRanges, image })}
|
||||||
|
className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-semibold text-[var(--r-on)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isEditing ? 'Save changes' : 'Create plan'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onCancel} className="text-sm text-[var(--r-sub)] hover:text-[var(--r-text)]">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One book row in the custom-plan builder: a checkbox, and — once checked — a
|
||||||
|
// "ch. X–Y" chapter-range pair that defaults to the whole book.
|
||||||
|
function BookRow({ book, checked, range, onToggle, onRangeChange }) {
|
||||||
|
const total = CHAPTER_COUNTS[book.abbrev] ?? 1;
|
||||||
|
return (
|
||||||
|
<label className="flex items-center gap-2 rounded-lg px-1 py-1 text-sm hover:bg-black/5">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={onToggle}
|
||||||
|
className="h-4 w-4 shrink-0 rounded border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] accent-[var(--r-accent)]"
|
||||||
|
/>
|
||||||
|
<span className="flex-1">{book.name}</span>
|
||||||
|
{checked && total > 1 && (
|
||||||
|
<span className="flex shrink-0 items-center gap-1 text-xs text-[var(--r-sub)]" onClick={(e) => e.preventDefault()}>
|
||||||
|
ch.
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={total}
|
||||||
|
value={range?.start ?? 1}
|
||||||
|
onChange={(e) => onRangeChange('start', e.target.value)}
|
||||||
|
className="w-11 rounded border border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] px-1 py-0.5 text-center"
|
||||||
|
/>
|
||||||
|
–
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={total}
|
||||||
|
value={range?.end ?? total}
|
||||||
|
onChange={(e) => onRangeChange('end', e.target.value)}
|
||||||
|
className="w-11 rounded border border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] px-1 py-0.5 text-center"
|
||||||
|
/>
|
||||||
|
<span className="text-[var(--r-sub)]">/{total}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
// Detail for one plan. Two modes: an in-progress plan (days to tick or jump into,
|
||||||
|
// its translation, catch-up / restart / remove) and a catalog plan (preview of the
|
||||||
|
// first days, pick a translation, start it).
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import Sheet from '../reader/Sheet.jsx';
|
||||||
|
import TranslationSelect from './TranslationSelect.jsx';
|
||||||
|
import { resizeImageToDataUrl } from './CustomPlanBuilder.jsx';
|
||||||
|
import { formatDayReadings, getPlanSchedule, daysBehindPace, nextUnreadDayIndex } from '../../utils/readingPlans.js';
|
||||||
|
import { useOfflineSave } from '../../hooks/useOfflineSave.js';
|
||||||
|
import { DownloadProgress } from '../OfflineSave.jsx';
|
||||||
|
import { canSaveOffline } from '../../utils/offline.js';
|
||||||
|
|
||||||
|
const btn = 'rounded-xl border border-[var(--r-line)] px-3 py-2 text-sm hover:bg-black/5';
|
||||||
|
const primary = 'w-full rounded-xl bg-[var(--r-accent)] py-2.5 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50';
|
||||||
|
|
||||||
|
function ActiveBody({ plan, progress, onClose, actions }) {
|
||||||
|
const schedule = getPlanSchedule(plan.id);
|
||||||
|
const behind = daysBehindPace(plan, progress);
|
||||||
|
const pct = plan.days > 0 ? Math.round((progress.completedDays.length / plan.days) * 100) : 0;
|
||||||
|
const offline = useOfflineSave();
|
||||||
|
const translation = progress.translation ?? actions.defaultTranslation;
|
||||||
|
// The next week of readings, so a trip or a bad connection doesn't interrupt the plan
|
||||||
|
const upcoming = (() => {
|
||||||
|
const from = nextUnreadDayIndex(plan, progress.completedDays);
|
||||||
|
return from == null ? [] : schedule.slice(from, from + 7).flat();
|
||||||
|
})();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mb-3 flex items-center justify-between text-sm">
|
||||||
|
<span>{progress.completedDays.length} of {plan.days} days · {pct}%</span>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-[var(--r-sub)]">Read in</span>
|
||||||
|
<TranslationSelect value={progress.translation ?? actions.defaultTranslation} onChange={(t) => actions.setTranslation(progress.id, t)} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3 h-1.5 overflow-hidden rounded-full bg-[var(--r-line)]"><div className="h-full bg-[var(--r-accent)]" style={{ width: `${pct}%` }} /></div>
|
||||||
|
{behind >= 2 && (
|
||||||
|
<div className="mb-3 flex items-center justify-between rounded-xl border border-[var(--r-line)] px-3 py-2 text-xs">
|
||||||
|
<span className="text-[var(--r-sub)]">{behind} days behind pace</span>
|
||||||
|
<button type="button" onClick={() => actions.recalibrate(progress.id)} className="font-medium text-[var(--r-accent)] hover:underline">Catch me up</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="max-h-[38vh] overflow-y-auto rounded-xl border border-[var(--r-line)]">
|
||||||
|
{schedule.map((refs, i) => {
|
||||||
|
const done = progress.completedDays.includes(i);
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex items-center gap-3 border-b border-[var(--r-line)] px-3 py-2 last:border-0">
|
||||||
|
<input type="checkbox" checked={done} onChange={() => actions.toggleDay(progress.id, i, plan.days)} className="h-4 w-4 shrink-0 accent-[var(--r-accent)]" aria-label={`Day ${i + 1} read`} />
|
||||||
|
<span className={`flex-1 text-sm ${done ? 'text-[var(--r-sub)] line-through' : ''}`}>Day {i + 1} · {formatDayReadings(refs)}</span>
|
||||||
|
<button type="button" onClick={() => { onClose(); actions.read(progress.id, i); }} className="shrink-0 text-xs font-medium text-[var(--r-accent)] hover:underline">Read</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{upcoming.length > 0 && canSaveOffline(translation) && (
|
||||||
|
<div className="mt-3">
|
||||||
|
{offline.job
|
||||||
|
? <DownloadProgress job={offline.job} onCancel={offline.cancel} />
|
||||||
|
: (
|
||||||
|
<button type="button" onClick={() => offline.saveRefs('the next 7 days', translation, upcoming)}
|
||||||
|
className="w-full rounded-xl border border-[var(--r-line)] px-3 py-2 text-xs text-[var(--r-sub)] hover:bg-black/5">
|
||||||
|
Save the next 7 days for offline ({translation})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-4 flex items-center justify-between text-xs">
|
||||||
|
<button type="button" onClick={() => { actions.restart(progress.id); onClose(); }} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">Restart</button>
|
||||||
|
<button type="button" onClick={() => { actions.remove(progress.id); onClose(); }} className="text-[var(--r-danger,#b42318)] hover:underline">Remove</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CatalogBody({ plan, active, completedCount, onClose, actions }) {
|
||||||
|
const [translation, setTranslation] = useState(actions.defaultTranslation);
|
||||||
|
const [imageError, setImageError] = useState('');
|
||||||
|
const fileRef = useRef(null);
|
||||||
|
const schedule = getPlanSchedule(plan.id);
|
||||||
|
const chapters = schedule.reduce((n, d) => n + d.length, 0);
|
||||||
|
const pickImage = async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = '';
|
||||||
|
if (!file) return;
|
||||||
|
try { setImageError(''); actions.updateCover(plan.id, await resizeImageToDataUrl(file)); } catch { setImageError('Couldn’t use that image.'); }
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{plan.image && <img src={plan.image} alt="" onError={(e) => { e.currentTarget.style.display = 'none'; }} className="mb-3 h-32 w-full rounded-xl object-cover" style={{ objectPosition: plan.imageFocus ?? 'center' }} />}
|
||||||
|
<p className="text-sm text-[var(--r-sub)]">{plan.description || 'A custom reading plan.'}</p>
|
||||||
|
<p className="mt-2 text-xs text-[var(--r-sub)]">{plan.days} days · {chapters} chapters · about {(chapters / plan.days).toFixed(1)} a day{completedCount > 0 ? ` · completed ${completedCount}×` : ''}</p>
|
||||||
|
|
||||||
|
<p className="mb-1 mt-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">First days</p>
|
||||||
|
<div className="rounded-xl border border-[var(--r-line)]">
|
||||||
|
{schedule.slice(0, 5).map((refs, i) => (
|
||||||
|
<div key={i} className="border-b border-[var(--r-line)] px-3 py-1.5 text-sm last:border-0">Day {i + 1} · {formatDayReadings(refs)}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!active && (
|
||||||
|
<div className="mt-4 flex items-center justify-between text-sm">
|
||||||
|
<span>Read in</span>
|
||||||
|
<TranslationSelect value={translation} onChange={setTranslation} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-3">
|
||||||
|
{active
|
||||||
|
? <button type="button" className={primary} onClick={() => { onClose(); actions.read(active.id, actions.nextDay(plan, active)); }}>Continue reading</button>
|
||||||
|
: <button type="button" className={primary} onClick={() => { actions.start(plan.id, translation); onClose(); }}>{completedCount > 0 ? 'Start again' : 'Start plan'}</button>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{plan.custom && (
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-3 border-t border-[var(--r-line)] pt-3 text-xs">
|
||||||
|
<button type="button" onClick={() => { onClose(); actions.edit(plan.id); }} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">Edit plan</button>
|
||||||
|
<button type="button" onClick={() => fileRef.current?.click()} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">{plan.image ? 'Change cover' : 'Add cover photo'}</button>
|
||||||
|
{plan.image && <button type="button" onClick={() => actions.updateCover(plan.id, null)} className="text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]">Remove cover</button>}
|
||||||
|
<button type="button" onClick={() => { actions.delete(plan.id); onClose(); }} className="ml-auto text-[var(--r-danger,#b42318)] hover:underline">Delete</button>
|
||||||
|
<input ref={fileRef} type="file" accept="image/*" onChange={pickImage} className="hidden" />
|
||||||
|
{imageError && <p className="w-full text-[var(--r-danger,#b42318)]">{imageError}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlanDetailSheet({ plan, progress, active, completedCount, onClose, actions }) {
|
||||||
|
if (!plan) return null;
|
||||||
|
return (
|
||||||
|
<Sheet open onClose={onClose} title={<span>{plan.icon ? `${plan.icon} ` : ''}{plan.title}</span>}>
|
||||||
|
{progress
|
||||||
|
? <ActiveBody plan={plan} progress={progress} onClose={onClose} actions={actions} />
|
||||||
|
: <CatalogBody plan={plan} active={active} completedCount={completedCount} onClose={onClose} actions={actions} />}
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { READER_TRANSLATIONS } from '../../hooks/useBibleReader.js';
|
||||||
|
|
||||||
|
// Every Bible the app can read in — used for a plan's own translation.
|
||||||
|
export default function TranslationSelect({ value, onChange, className = '' }) {
|
||||||
|
return (
|
||||||
|
<select value={value} onChange={(e) => onChange(e.target.value)}
|
||||||
|
className={`rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1 text-sm text-[var(--r-text)] ${className}`}>
|
||||||
|
{READER_TRANSLATIONS.map((t) => <option key={t.id} value={t.id}>{t.id} · {t.name}</option>)}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import Sheet from './Sheet.jsx';
|
||||||
|
import AudioPlayer from '../AudioPlayer.jsx';
|
||||||
|
|
||||||
|
// Wraps the existing player. Audio is BSB-only, so licensed translations get a note.
|
||||||
|
export default function AudioSheet({ open, onClose, translation, bookName, chapter, audio, narrator, onNarrator, onNext, nextDisabled }) {
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onClose={onClose} title="Listen">
|
||||||
|
<AudioPlayer
|
||||||
|
title={`${bookName} ${chapter}`}
|
||||||
|
subtitle={audio.state.status === 'error'
|
||||||
|
? "Couldn't load audio for this chapter/narrator."
|
||||||
|
: translation === 'ESV'
|
||||||
|
? 'ESV audio, read by David Cochran Heath. It follows the chapter as you browse.'
|
||||||
|
: translation !== 'BSB'
|
||||||
|
? `Audio is BSB only — no ${translation} audio is available, so it won't match the ${translation} text on screen.`
|
||||||
|
: 'Audio follows the chapter as you browse.'}
|
||||||
|
status={audio.state.status}
|
||||||
|
currentTime={audio.progress.currentTime}
|
||||||
|
duration={audio.progress.duration}
|
||||||
|
onPlay={audio.play}
|
||||||
|
onTogglePause={audio.togglePause}
|
||||||
|
onSeekBack={() => audio.seek(-10)}
|
||||||
|
onSeekForward={() => audio.seek(10)}
|
||||||
|
onSeekTo={audio.seekTo}
|
||||||
|
onNext={onNext}
|
||||||
|
nextDisabled={nextDisabled}
|
||||||
|
onStop={audio.stop}
|
||||||
|
controls={translation === 'ESV' ? null : (
|
||||||
|
<select value={narrator} onChange={(e) => onNarrator(e.target.value)}
|
||||||
|
className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1.5 text-sm text-[var(--r-text)]">
|
||||||
|
<option value="david">David</option>
|
||||||
|
<option value="hays">Hays</option>
|
||||||
|
<option value="souer">Souer</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Compare translations: pick a second Bible to read beside the one you're reading, and whether the
|
||||||
|
// words that differ are highlighted. The chapter shows how different the two are.
|
||||||
|
import Sheet from './Sheet.jsx';
|
||||||
|
import { READER_TRANSLATIONS } from '../../utils/bibleText.js';
|
||||||
|
|
||||||
|
export default function CompareSheet({ open, onClose, translation, parallel, onParallel, showDiff, onShowDiff }) {
|
||||||
|
const chip = (active) => `rounded-full border px-3 py-1.5 text-sm font-medium ${active ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`;
|
||||||
|
const options = READER_TRANSLATIONS.filter((t) => t.id !== translation);
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onClose={onClose} title="Compare translations">
|
||||||
|
<p className="mb-3 text-sm text-[var(--r-sub)]">
|
||||||
|
Read another Bible beside the {translation}. You’ll see how different its wording is for the whole chapter and for each verse.
|
||||||
|
</p>
|
||||||
|
<span className="mb-1.5 block text-xs text-[var(--r-sub)]">Compare {translation} with</span>
|
||||||
|
<div role="group" aria-label="Translation to compare with" className="mb-4 flex flex-wrap gap-1.5">
|
||||||
|
{options.map((t) => (
|
||||||
|
<button key={t.id} type="button" title={t.name} aria-pressed={parallel === t.id} onClick={() => { onParallel(parallel === t.id ? '' : t.id); if (parallel !== t.id) onClose(); }} className={chip(parallel === t.id)}>{t.id}</button>
|
||||||
|
))}
|
||||||
|
<button type="button" aria-pressed={!parallel} onClick={() => onParallel('')} className={chip(!parallel)}>Off</button>
|
||||||
|
</div>
|
||||||
|
{parallel && (
|
||||||
|
<>
|
||||||
|
<span className="mb-1.5 block text-xs text-[var(--r-sub)]">Highlight the words that differ</span>
|
||||||
|
<div role="group" aria-label="Highlight differences" className="flex overflow-hidden rounded-xl border border-[var(--r-line)]">
|
||||||
|
{[[true, 'Show'], [false, 'Hide']].map(([id, text]) => (
|
||||||
|
<button key={String(id)} type="button" aria-pressed={showDiff === id} onClick={() => onShowDiff(id)}
|
||||||
|
className={`flex-1 px-3 py-2 text-sm ${showDiff === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{text}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<p className="mt-4 text-[11px] text-[var(--r-sub)]">The wording is compared word by word, ignoring capitals and punctuation. It shows how the wording differs, not which translation is more accurate. Tap any verse and open Compare to see all six translations of it.</p>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// Text with the words that differ from the other translation marked.
|
||||||
|
export function DiffText({ text, ranges = [] }) {
|
||||||
|
if (!ranges.length) return text;
|
||||||
|
const parts = [];
|
||||||
|
let cursor = 0;
|
||||||
|
ranges.forEach((r, i) => {
|
||||||
|
if (r.start > cursor) parts.push(text.slice(cursor, r.start));
|
||||||
|
parts.push(<span key={i} className="diff-hl">{text.slice(r.start, r.end)}</span>);
|
||||||
|
cursor = r.end;
|
||||||
|
});
|
||||||
|
if (cursor < text.length) parts.push(text.slice(cursor));
|
||||||
|
return <>{parts}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// Everything you have marked in the Bible: highlights (grouped and filtered by colour, each colour
|
||||||
|
// named by you — "Promises", "Commands") and bookmarks. Opens from the bookmark button in the top bar.
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Sheet from './Sheet.jsx';
|
||||||
|
import { CloseIcon } from './icons.jsx';
|
||||||
|
import { COLOR_NAMES, COLOR_SUGGESTIONS, HIGHLIGHT_COLORS, colorTitle } from '../../utils/highlightColors.js';
|
||||||
|
|
||||||
|
const tabBtn = (active) => `rounded-full border px-3 py-1 text-xs font-medium ${active ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`;
|
||||||
|
|
||||||
|
export default function MarksSheet({
|
||||||
|
open, onClose, bookmarks, highlights, colorLabels, onSetLabel, onJump, onRemoveBookmark, onRemoveHighlight,
|
||||||
|
}) {
|
||||||
|
const [tab, setTab] = useState('highlights');
|
||||||
|
const [filter, setFilter] = useState(null); // a highlight colour, or null for all
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
||||||
|
const counts = Object.fromEntries(HIGHLIGHT_COLORS.map((c) => [c, highlights.filter((h) => h.color === c).length]));
|
||||||
|
const shown = filter ? highlights.filter((h) => h.color === filter) : highlights;
|
||||||
|
// Colours in use that aren't one of the picker's (older data) still get listed
|
||||||
|
const total = highlights.length + bookmarks.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onClose={onClose} label="Bookmarks and highlights" title={`Bookmarks & highlights${total ? ` (${total})` : ''}`}>
|
||||||
|
<div role="tablist" aria-label="What to show" className="mb-3 flex gap-1.5">
|
||||||
|
<button type="button" role="tab" aria-selected={tab === 'highlights'} onClick={() => setTab('highlights')} className={tabBtn(tab === 'highlights')}>Highlights ({highlights.length})</button>
|
||||||
|
<button type="button" role="tab" aria-selected={tab === 'bookmarks'} onClick={() => setTab('bookmarks')} className={tabBtn(tab === 'bookmarks')}>Bookmarks ({bookmarks.length})</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'highlights' && (
|
||||||
|
<>
|
||||||
|
{/* Colour key: tap a colour to show only that one; "Name colours" lets you say what each means */}
|
||||||
|
<div className="mb-3 flex flex-wrap items-center gap-1.5" role="group" aria-label="Filter by colour">
|
||||||
|
<button type="button" onClick={() => setFilter(null)} aria-pressed={filter === null} className={tabBtn(filter === null)}>All</button>
|
||||||
|
{HIGHLIGHT_COLORS.map((c) => (
|
||||||
|
<button key={c} type="button" onClick={() => setFilter(filter === c ? null : c)} aria-pressed={filter === c}
|
||||||
|
title={colorTitle(c, colorLabels)}
|
||||||
|
className={`flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs ${filter === c ? 'border-[var(--r-text)]' : 'border-[var(--r-line)] hover:bg-black/5'}`}>
|
||||||
|
<span className="h-3 w-3 rounded-full border border-black/10" style={{ backgroundColor: c }} />
|
||||||
|
<span>{colorLabels[c] || COLOR_NAMES[c]}</span>
|
||||||
|
<span className="text-[var(--r-sub)]">{counts[c]}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button type="button" onClick={() => setEditing((e) => !e)} className="ml-auto text-xs text-[var(--r-accent)] hover:underline">{editing ? 'Done' : 'Name colours'}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<div className="mb-3 grid gap-2 rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-3 sm:grid-cols-2">
|
||||||
|
{HIGHLIGHT_COLORS.map((c) => (
|
||||||
|
<label key={c} className="flex items-center gap-2 text-xs text-[var(--r-sub)]">
|
||||||
|
<span className="h-4 w-4 shrink-0 rounded-full border border-black/10" style={{ backgroundColor: c }} />
|
||||||
|
<span className="sr-only">{COLOR_NAMES[c]} means</span>
|
||||||
|
<input value={colorLabels[c] ?? ''} maxLength={30} onChange={(e) => onSetLabel(c, e.target.value)} placeholder={COLOR_SUGGESTIONS[c]}
|
||||||
|
className="min-w-0 flex-1 rounded-lg border border-[var(--r-line)] bg-transparent px-2 py-1 text-sm text-[var(--r-text)] outline-none focus:border-[var(--r-accent)]" />
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{highlights.length === 0 ? (
|
||||||
|
<p className="text-sm text-[var(--r-sub)]">No highlights yet. Select some words in a verse and pick a colour to save them here.</p>
|
||||||
|
) : shown.length === 0 ? (
|
||||||
|
<p className="text-sm text-[var(--r-sub)]">Nothing highlighted in that colour yet.</p>
|
||||||
|
) : shown.map((h) => (
|
||||||
|
<div key={h.id} className="flex items-start gap-2 rounded-lg px-1 py-1.5 hover:bg-black/5">
|
||||||
|
<span className="mt-1 h-3 w-3 shrink-0 rounded-full border border-black/10" style={{ backgroundColor: h.color }} title={colorTitle(h.color, colorLabels)} />
|
||||||
|
<button type="button" onClick={() => onJump(h.book, h.chapter, h.verse)} className="min-w-0 flex-1 text-left text-sm">
|
||||||
|
<span className="font-medium">{h.bookName} {h.chapter}:{h.verse}</span>
|
||||||
|
{colorLabels[h.color] && <span className="ml-1.5 text-[11px] text-[var(--r-sub)]">{colorLabels[h.color]}</span>}
|
||||||
|
{h.text && <span className="mt-0.5 block font-serif text-[var(--r-sub)]">“{h.text}”</span>}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => onRemoveHighlight(h)} aria-label={`Remove highlight on ${h.bookName} ${h.chapter}:${h.verse}`}
|
||||||
|
className="rounded p-1 text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]"><CloseIcon /></button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'bookmarks' && (
|
||||||
|
bookmarks.length === 0 ? (
|
||||||
|
<p className="text-sm text-[var(--r-sub)]">No bookmarks yet. Tap a verse, then the bookmark icon, to save it here.</p>
|
||||||
|
) : bookmarks.map((e) => (
|
||||||
|
<div key={e.key} className="flex items-center gap-2 rounded-lg px-1 py-1.5 hover:bg-black/5">
|
||||||
|
<span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: e.color }} />
|
||||||
|
<button type="button" onClick={() => onJump(e.bookAbbrev, e.chapter, e.verse)} className="flex-1 truncate text-left text-sm">
|
||||||
|
{e.bookName} {e.chapter}:{e.verse}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => onRemoveBookmark(e)} aria-label={`Remove bookmark on ${e.bookName} ${e.chapter}:${e.verse}`}
|
||||||
|
className="rounded p-1 text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]"><CloseIcon /></button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// One place to pick translation, book and chapter (replaces three dropdowns).
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Sheet from './Sheet.jsx';
|
||||||
|
import { bookOptions, CHAPTER_COUNTS } from '../../utils/bibleUtils.jsx';
|
||||||
|
import { READER_TRANSLATIONS } from '../../utils/bibleText.js';
|
||||||
|
import { SaveBookOffline } from '../OfflineSave.jsx';
|
||||||
|
|
||||||
|
const NT_START = bookOptions.findIndex((b) => b.abbrev === 'MAT');
|
||||||
|
const chip = 'rounded-lg border px-2 py-2 text-center text-sm transition';
|
||||||
|
|
||||||
|
export default function NavigatorSheet({ open, onClose, bookAbbrev, chapter, translation, onTranslation, translationNote, onGo, offline }) {
|
||||||
|
const [pickedBook, setPickedBook] = useState(null); // book abbrev whose chapters are showing
|
||||||
|
const close = () => { setPickedBook(null); onClose(); };
|
||||||
|
const book = pickedBook ? bookOptions.find((b) => b.abbrev === pickedBook) : null;
|
||||||
|
|
||||||
|
const bookButton = (b) => (
|
||||||
|
<button key={b.abbrev} type="button" onClick={() => setPickedBook(b.abbrev)}
|
||||||
|
className={`${chip} truncate ${b.abbrev === bookAbbrev ? 'border-[var(--r-accent)] text-[var(--r-accent)]' : 'border-[var(--r-line)] hover:bg-black/5'}`}>
|
||||||
|
{b.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onClose={close} label="Go to" title={book ? (
|
||||||
|
<button type="button" onClick={() => setPickedBook(null)} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">‹ All books · <span className="text-[var(--r-text)]">{book.name}</span></button>
|
||||||
|
) : 'Go to'}>
|
||||||
|
{!book && (
|
||||||
|
<>
|
||||||
|
<div className="mb-3 flex flex-wrap gap-1.5">
|
||||||
|
{READER_TRANSLATIONS.map((t) => (
|
||||||
|
<button key={t.id} type="button" onClick={() => onTranslation(t.id)} title={t.name}
|
||||||
|
className={`rounded-full border px-3 py-1 text-xs font-medium ${t.id === translation ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
|
||||||
|
{t.id}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{translationNote && <p className="-mt-1 mb-3 text-[11px] text-[var(--r-sub)]">{translationNote}</p>}
|
||||||
|
<p className="mb-1.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Old Testament</p>
|
||||||
|
<div className="mb-4 grid grid-cols-3 gap-1.5 sm:grid-cols-4">{bookOptions.slice(0, NT_START).map(bookButton)}</div>
|
||||||
|
<p className="mb-1.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">New Testament</p>
|
||||||
|
<div className="grid grid-cols-3 gap-1.5 sm:grid-cols-4">{bookOptions.slice(NT_START).map(bookButton)}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{book && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-6 gap-1.5 sm:grid-cols-8">
|
||||||
|
{Array.from({ length: CHAPTER_COUNTS[book.abbrev] ?? 1 }, (_, i) => i + 1).map((n) => (
|
||||||
|
<button key={n} type="button" onClick={() => { onGo(book.abbrev, n); close(); }}
|
||||||
|
className={`${chip} ${book.abbrev === bookAbbrev && n === chapter ? 'border-[var(--r-accent)] bg-[var(--r-accent)] text-[var(--r-on)]' : 'border-[var(--r-line)] hover:bg-black/5'}`}>
|
||||||
|
{n}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{offline && <SaveBookOffline offline={offline} translation={translation} book={book.abbrev} name={book.name} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Card at the end of the chapter while reading a plan: step to the next chapter of
|
||||||
|
// today's reading, or finish the day and roll into the next one.
|
||||||
|
export default function PlanFooter({ info, label, onNext, onNextDay, onFinish }) {
|
||||||
|
if (!info || info.position === 0) return null;
|
||||||
|
const last = info.position === info.total;
|
||||||
|
const nextRef = !last ? info.refs[info.position] : null;
|
||||||
|
const btn = 'rounded-full bg-[var(--r-accent)] px-5 py-2 text-sm font-medium text-[var(--r-on)]';
|
||||||
|
return (
|
||||||
|
<div className="mx-auto mt-10 max-w-md rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-4 text-center font-sans">
|
||||||
|
<p className="text-xs text-[var(--r-sub)]">{info.plan.title} · Day {info.dayIndex + 1}</p>
|
||||||
|
{!last && (
|
||||||
|
<>
|
||||||
|
<p className="mb-3 mt-1 text-sm font-medium text-[var(--r-text)]">{info.total - info.position} more chapter{info.total - info.position === 1 ? '' : 's'} today</p>
|
||||||
|
<button type="button" onClick={onNext} className={btn}>Next: {label(nextRef)}</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{last && info.nextDayRefs?.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p className="mb-3 mt-1 text-sm font-medium text-[var(--r-text)]">That’s Day {info.dayIndex + 1}</p>
|
||||||
|
<button type="button" onClick={onNextDay} className={btn}>Mark read · Day {info.dayIndex + 2}: {label(info.nextDayRefs[0])}</button>
|
||||||
|
<div><button type="button" onClick={onFinish} className="mt-2 text-xs text-[var(--r-sub)] underline">Mark read and stop for today</button></div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{last && !info.nextDayRefs?.length && (
|
||||||
|
<>
|
||||||
|
<p className="mb-3 mt-1 text-sm font-medium text-[var(--r-text)]">Last day of the plan</p>
|
||||||
|
<button type="button" onClick={onFinish} className={btn}>{info.done ? 'Plan complete' : 'Finish plan'}</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// The plan's control room, one tap from the reader: pick the day, tick chapters,
|
||||||
|
// choose the Bible this plan is read in, and jump to another active plan.
|
||||||
|
import Sheet from './Sheet.jsx';
|
||||||
|
import { ChevronLeft, ChevronRight, CheckIcon } from './icons.jsx';
|
||||||
|
import { READER_TRANSLATIONS } from '../../hooks/useBibleReader.js';
|
||||||
|
import { formatDayReadings } from '../../utils/readingPlans.js';
|
||||||
|
|
||||||
|
export default function PlanSheet({
|
||||||
|
open, onClose, info, translation, currentKey, streak, otherPlans, studyBusy,
|
||||||
|
onDay, onToggleChapter, onGoChapter, onTranslation, onToggleDay, onSwitchPlan, onListen, onStudy, onLeave,
|
||||||
|
}) {
|
||||||
|
if (!info) return null;
|
||||||
|
const label = (r) => formatDayReadings([r]);
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onClose={onClose} title={info.plan.title}>
|
||||||
|
<div className="mb-1 flex items-center justify-between">
|
||||||
|
<button type="button" onClick={() => onDay(-1)} disabled={info.dayIndex <= 0} aria-label="Previous day"
|
||||||
|
className="rounded-lg p-1.5 text-[var(--r-sub)] hover:bg-black/5 disabled:opacity-30"><ChevronLeft /></button>
|
||||||
|
<span className="text-sm font-medium">Day {info.dayIndex + 1} of {info.plan.days}{info.done ? ' · read' : ''}</span>
|
||||||
|
<button type="button" onClick={() => onDay(1)} disabled={info.dayIndex >= info.plan.days - 1} aria-label="Next day"
|
||||||
|
className="rounded-lg p-1.5 text-[var(--r-sub)] hover:bg-black/5 disabled:opacity-30"><ChevronRight /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mb-1 mt-3 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Reading</p>
|
||||||
|
{info.refs.map((r) => {
|
||||||
|
const key = `${r.book}.${r.chapter}`;
|
||||||
|
const read = info.readSet.has(key);
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex items-center gap-3 border-b border-[var(--r-line)] py-2">
|
||||||
|
<button type="button" onClick={() => onToggleChapter(key)} aria-label={read ? 'Mark unread' : 'Mark read'}
|
||||||
|
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-[1.5px] ${read ? 'border-[var(--r-accent)] bg-[var(--r-accent)] text-[var(--r-on)]' : 'border-[var(--r-sub)] text-transparent'}`}>
|
||||||
|
<span className="scale-75"><CheckIcon /></span>
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => onGoChapter(r)} className={`flex-1 text-left text-sm ${key === currentKey ? 'font-medium' : ''}`}>{label(r)}</button>
|
||||||
|
{key === currentKey && <span className="rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[10px] text-[var(--r-sub)]">reading</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button type="button" onClick={onToggleDay}
|
||||||
|
className="mt-3 w-full rounded-xl border border-[var(--r-line)] py-2 text-sm hover:bg-black/5">
|
||||||
|
{info.done ? 'Mark this day unread' : 'Mark the whole day read'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="mb-1 mt-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">This plan</p>
|
||||||
|
<div className="flex items-center justify-between py-1.5 text-sm">
|
||||||
|
<span>Read in</span>
|
||||||
|
<select value={translation} onChange={(e) => onTranslation(e.target.value)}
|
||||||
|
className="rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1 text-sm text-[var(--r-text)]">
|
||||||
|
{READER_TRANSLATIONS.map((t) => <option key={t.id} value={t.id}>{t.id} · {t.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between py-1.5 text-sm">
|
||||||
|
<span>Streak</span><span className="text-[var(--r-sub)]">{streak.current} day{streak.current === 1 ? '' : 's'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<button type="button" onClick={onListen} className="flex-1 rounded-xl border border-[var(--r-line)] py-2 text-sm hover:bg-black/5">Listen</button>
|
||||||
|
<button type="button" onClick={onStudy} disabled={studyBusy} className="flex-1 rounded-xl border border-[var(--r-line)] py-2 text-sm hover:bg-black/5 disabled:opacity-50">{studyBusy ? 'Building…' : 'Study this day'}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{otherPlans.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p className="mb-1 mt-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Other plans</p>
|
||||||
|
{otherPlans.map((o) => (
|
||||||
|
<button key={o.progress.id} type="button" onClick={() => onSwitchPlan(o)}
|
||||||
|
className="flex w-full items-center justify-between rounded-lg px-1 py-2 text-left text-sm hover:bg-black/5">
|
||||||
|
<span className="truncate">{o.plan.title}</span>
|
||||||
|
<span className="ml-3 shrink-0 text-xs text-[var(--r-sub)]">Day {o.dayIndex + 1}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button type="button" onClick={onLeave} className="mt-4 w-full text-center text-xs text-[var(--r-sub)] underline">Leave plan (keep reading on my own)</button>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Thin strip under the top bar while reading a plan: which plan and day, one dot
|
||||||
|
// per chapter of today's reading (filled = read, ringed = the chapter on screen),
|
||||||
|
// and the translation. Tapping it opens the plan sheet. When you've wandered off
|
||||||
|
// today's chapters (e.g. following a cross-reference) it offers a way back.
|
||||||
|
export default function PlanStrip({ info, translation, inPlan, notice, onOpen, onJoin, onDot, onBackToDay }) {
|
||||||
|
if (!info && !notice) return null;
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex max-w-2xl flex-col gap-1 px-3 pt-2 font-sans">
|
||||||
|
{notice && <p className="rounded-lg bg-[var(--r-bar)] px-3 py-1.5 text-xs text-[var(--r-accent)]">{notice}</p>}
|
||||||
|
{info && (
|
||||||
|
<div className="rounded-xl bg-[var(--r-bar)] px-3 py-2 text-xs text-[var(--r-sub)]">
|
||||||
|
<button type="button" onClick={inPlan ? onOpen : onJoin} className="flex w-full items-center justify-between gap-3 text-left">
|
||||||
|
<span className="truncate font-medium text-[var(--r-text)]">{info.plan.title} · Day {info.dayIndex + 1}</span>
|
||||||
|
<span className="shrink-0">{inPlan ? translation : 'Open plan'}</span>
|
||||||
|
</button>
|
||||||
|
{inPlan && info.position === 0 && (
|
||||||
|
<button type="button" onClick={onBackToDay} className="mt-1.5 rounded-full border border-[var(--r-line)] px-3 py-1 text-[11px] text-[var(--r-accent)] hover:bg-black/5">
|
||||||
|
Back to today’s reading
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{inPlan && info.position > 0 && (
|
||||||
|
<div className="mt-1.5 flex items-center gap-1.5">
|
||||||
|
{info.refs.map((r, i) => {
|
||||||
|
const key = `${r.book}.${r.chapter}`;
|
||||||
|
const read = info.readSet.has(key);
|
||||||
|
const current = i + 1 === info.position;
|
||||||
|
return (
|
||||||
|
<button key={key} type="button" onClick={() => onDot(r)} aria-label={`Go to chapter ${r.chapter}`}
|
||||||
|
className={`h-2.5 w-2.5 rounded-full border-[1.5px] ${read ? 'border-[var(--r-accent)] bg-[var(--r-accent)]' : 'border-[var(--r-sub)]'} ${current ? 'ring-2 ring-[var(--r-line)]' : ''}`} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<span className="ml-auto">{info.done ? 'Day read' : `${info.position} of ${info.total}`}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// 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 { 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 }) {
|
||||||
|
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={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">
|
||||||
|
<span className="truncate">{title}</span><ChevronDown />
|
||||||
|
</button>
|
||||||
|
{onDoneDrawing ? (
|
||||||
|
<button type="button" onClick={onDoneDrawing}
|
||||||
|
className="mr-1 flex items-center gap-1.5 rounded-full bg-[var(--r-accent)] px-4 py-1.5 text-xs font-medium text-[var(--r-on)]">
|
||||||
|
<CheckIcon />Done drawing
|
||||||
|
</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">
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BottomBar({
|
||||||
|
onPrev, onNext, prevDisabled, nextDisabled,
|
||||||
|
onAudio, audioActive, onPen, onCrossRefs, crossRefsOn, onFocus, primary,
|
||||||
|
}) {
|
||||||
|
const active = 'text-[var(--r-accent)]';
|
||||||
|
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>
|
||||||
|
<div className="mx-auto" />
|
||||||
|
{primary && (
|
||||||
|
<button type="button" onClick={primary.onClick}
|
||||||
|
className="mr-1 flex items-center gap-1.5 rounded-full bg-[var(--r-accent)] px-4 py-2 text-xs font-medium text-[var(--r-on)]">
|
||||||
|
{primary.done ? null : <CheckIcon />}{primary.label}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="button" onClick={onNext} disabled={nextDisabled} className={iconBtn} aria-label="Next chapter"><ChevronRight /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
// The page itself: chapter heading, translation notice, and verses set in a
|
||||||
|
// serif face. Tap a verse to open the verse sheet; select text to highlight.
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { formatCrossRef } from '../../utils/wordStudy.js';
|
||||||
|
import { diffChapter, differenceLabel } from '../../utils/textDiff.js';
|
||||||
|
import { DiffText } from './DiffText.jsx';
|
||||||
|
|
||||||
|
// How each typeface choice is set. "Easy to read" is a sans face with extra letter and word spacing.
|
||||||
|
const FONT_STYLES = {
|
||||||
|
serif: { fontFamily: undefined },
|
||||||
|
sans: { fontFamily: "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif" },
|
||||||
|
easy: { fontFamily: "'Atkinson Hyperlegible', Verdana, 'Trebuchet MS', ui-sans-serif, sans-serif", letterSpacing: '0.02em', wordSpacing: '0.12em' },
|
||||||
|
};
|
||||||
|
const LINE_HEIGHTS = { snug: 1.6, normal: 1.85, airy: 2.15 };
|
||||||
|
|
||||||
|
// `parallel` ({ translation, name, notice, verses, loading, error }) shows a second Bible beside the first.
|
||||||
|
export default function ReaderText({
|
||||||
|
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,
|
||||||
|
}) {
|
||||||
|
// How much the second Bible's wording differs from the first, over the chapter and verse by verse
|
||||||
|
const diff = useMemo(
|
||||||
|
() => (parallel && showDiff && !parallel.loading && !parallel.error && parallel.verses?.length ? diffChapter(verses, parallel.verses) : null),
|
||||||
|
[parallel, showDiff, verses],
|
||||||
|
);
|
||||||
|
const diffByVerse = useMemo(() => new Map((diff?.verses ?? []).map((d) => [d.number, d])), [diff]);
|
||||||
|
const jumpToVerse = (n) => document.getElementById(`reader-verse-${n}`)?.scrollIntoView?.({ block: 'center', behavior: window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
|
||||||
|
|
||||||
|
const handleTap = (n) => {
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (sel && !sel.isCollapsed && sel.toString().trim()) return; // user is selecting text, not tapping
|
||||||
|
onSelectVerse(selectedVerse === n ? null : n);
|
||||||
|
};
|
||||||
|
|
||||||
|
const verseProps = (v) => {
|
||||||
|
const bm = bookmarks[`${bookKey}-${chapter}-${v.number}`];
|
||||||
|
const selected = selectedVerse === v.number;
|
||||||
|
return {
|
||||||
|
id: `reader-verse-${v.number}`,
|
||||||
|
'data-verse-num': v.number,
|
||||||
|
onClick: () => handleTap(v.number),
|
||||||
|
// Verses can be reached and chosen from the keyboard too
|
||||||
|
role: 'button',
|
||||||
|
tabIndex: 0,
|
||||||
|
'aria-pressed': selected,
|
||||||
|
onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleTap(v.number); } },
|
||||||
|
className: 'cursor-pointer rounded-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[var(--r-accent)]',
|
||||||
|
style: selected
|
||||||
|
? { backgroundColor: 'var(--r-hl)' }
|
||||||
|
: bm ? { backgroundColor: `${bm}66`, boxShadow: `0 0 0 2px ${bm}66` } : undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
const num = (n) => (showVerseNumbers ? <sup className="mr-1 select-none font-sans text-[0.6em] text-[var(--r-sub)]">{n}</sup> : null);
|
||||||
|
|
||||||
|
// Both Bibles by verse number, so a verse one translation leaves out still lines up with the other.
|
||||||
|
const parallelRows = parallel && !parallel.error ? (() => {
|
||||||
|
const second = new Map(parallel.verses.map((v) => [v.number, v]));
|
||||||
|
const first = new Map(verses.map((v) => [v.number, v]));
|
||||||
|
return [...new Set([...first.keys(), ...second.keys()])].sort((a, b) => a - b)
|
||||||
|
.map((n) => ({ number: n, first: first.get(n), second: second.get(n) }));
|
||||||
|
})() : null;
|
||||||
|
const sideBySide = !!parallel;
|
||||||
|
const bodyStyle = {
|
||||||
|
...FONT_STYLES[font],
|
||||||
|
lineHeight: LINE_HEIGHTS[spacing] ?? LINE_HEIGHTS.normal,
|
||||||
|
};
|
||||||
|
// The page grows with the screen: type is 18px on a phone, ~20px on an iPad and ~24px on a big
|
||||||
|
// monitor (times your text-size choice), and the column is measured in the type's own width (em), so
|
||||||
|
// the line length stays comfortable (~70 characters) instead of becoming a narrow strip on a wide
|
||||||
|
// screen. Two columns / side-by-side take more room.
|
||||||
|
const pageStyle = {
|
||||||
|
fontSize: `calc(clamp(1.125rem, 0.9rem + 0.7vw, 1.55rem) * ${fontSize})`,
|
||||||
|
maxWidth: wide || sideBySide ? 'min(100%, 72em)' : 'min(100%, 40em)',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className="mx-auto px-5 pb-32 pt-6 text-[var(--r-text)] md:px-8"
|
||||||
|
style={pageStyle}
|
||||||
|
onPointerUp={onSelectionEnd}
|
||||||
|
>
|
||||||
|
<header className="mb-5 text-center">
|
||||||
|
<h1 className="font-serif text-3xl font-normal tracking-tight">{title}</h1>
|
||||||
|
<p className="mt-1 font-sans text-xs text-[var(--r-sub)]">{translation}{parallel ? ` · beside ${parallel.name}` : ''}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{loading && <ChapterSkeleton />}
|
||||||
|
{error && <p role="alert" className="text-center font-sans text-sm text-[var(--r-danger,#b42318)]">{error}</p>}
|
||||||
|
{!loading && !error && parallel?.error && (
|
||||||
|
<p role="alert" className="mb-4 text-center font-sans text-sm text-[var(--r-danger,#b42318)]">Couldn’t load {parallel.name}: {parallel.error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && sideBySide && !parallel.error && (
|
||||||
|
<div style={bodyStyle} className="font-serif">
|
||||||
|
{diff && (
|
||||||
|
<div className="mb-4 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-3 font-sans text-sm" style={{ fontSize: '0.875rem' }}>
|
||||||
|
<p>
|
||||||
|
<strong>{parallel.translation}</strong>{diff.similarity === 1 ? ' uses the same words as ' : <> is <strong>{differenceLabel(diff.similarity)}</strong> from </>}{translationId} in this chapter
|
||||||
|
{diff.total > 0 && <span className="text-[var(--r-sub)]"> · {diff.differing} of {diff.total} verses differ</span>}
|
||||||
|
</p>
|
||||||
|
{diff.mostDifferent.length > 0 && (
|
||||||
|
<p className="mt-1 text-xs text-[var(--r-sub)]">
|
||||||
|
Most different:{' '}
|
||||||
|
{diff.mostDifferent.map((v, i) => (
|
||||||
|
<span key={v.number}>{i > 0 && ', '}<button type="button" onClick={() => jumpToVerse(v.number)} className="font-medium text-[var(--r-accent)] hover:underline">verse {v.number}</button> ({Math.round((1 - v.similarity) * 100)}%)</span>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-1 text-xs text-[var(--r-sub)]"><span className="diff-hl">Marked words</span> aren’t in the other wording.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mb-3 hidden grid-cols-2 gap-x-10 border-b border-[var(--r-line)] pb-1 font-sans text-[11px] uppercase tracking-wide text-[var(--r-sub)] md:grid">
|
||||||
|
<span>{translationId}</span><span>{parallel.translation}</span>
|
||||||
|
</div>
|
||||||
|
{parallelRows.map((row) => (
|
||||||
|
<div key={row.number} className="mb-4 grid gap-x-10 gap-y-1 md:grid-cols-2">
|
||||||
|
<p>
|
||||||
|
{row.first
|
||||||
|
? <span {...verseProps(row.first)}>{num(row.first.number)}{renderVerseText(row.first.number, row.first.text, searchLower, diffByVerse.get(row.number)?.a)}{chips(row.first)}</span>
|
||||||
|
: <span className="text-[var(--r-sub)]">{num(row.number)}—</span>}
|
||||||
|
</p>
|
||||||
|
<p className="border-l-2 border-[var(--r-line)] pl-3 text-[0.95em] text-[var(--r-sub)] md:border-l-0 md:pl-0 md:text-[var(--r-text)]">
|
||||||
|
<span className="mr-1.5 font-sans text-[10px] uppercase tracking-wide md:hidden">{parallel.translation}</span>
|
||||||
|
{row.second ? <>{num(row.second.number)}<DiffText text={row.second.text} ranges={diffByVerse.get(row.number)?.b} />{diffByVerse.get(row.number) && !diffByVerse.get(row.number).same && (
|
||||||
|
<span className="ml-2 whitespace-nowrap font-sans text-[10px] text-[var(--r-sub)]">{differenceLabel(diffByVerse.get(row.number).similarity)}</span>
|
||||||
|
)}</> : parallel.loading ? <span className="font-sans text-xs" aria-label="Loading">…</span> : '—'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && !sideBySide && (
|
||||||
|
<div
|
||||||
|
className={`font-serif ${wide ? 'md:columns-2 md:gap-x-12' : ''}`}
|
||||||
|
style={bodyStyle}
|
||||||
|
>
|
||||||
|
{layout === 'flow' ? (
|
||||||
|
<p className="text-pretty">
|
||||||
|
{verses.map((v) => (
|
||||||
|
<span key={v.number} {...verseProps(v)}>
|
||||||
|
{num(v.number)}{renderVerseText(v.number, v.text, searchLower)}{chips(v)}{' '}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
verses.map((v) => (
|
||||||
|
<p key={v.number} className="mb-3 break-inside-avoid">
|
||||||
|
<span {...verseProps(v)}>{num(v.number)}{renderVerseText(v.number, v.text, searchLower)}{chips(v)}</span>
|
||||||
|
</p>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && <div style={{ fontSize: '1rem' }}>{footer}</div>}
|
||||||
|
|
||||||
|
{notice && !loading && <p className="mt-10 border-t border-[var(--r-line)] pt-3 font-sans text-[11px] leading-snug text-[var(--r-sub)]">{notice}</p>}
|
||||||
|
{parallel?.notice && !loading && !parallel.loading && <p className="mt-2 font-sans text-[11px] leading-snug text-[var(--r-sub)]">{parallel.notice}</p>}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grey bars the shape of a page, so a slow chapter load doesn't just show the word "Loading".
|
||||||
|
function ChapterSkeleton() {
|
||||||
|
return (
|
||||||
|
<div role="status" aria-label="Loading chapter" className="animate-pulse motion-reduce:animate-none space-y-3 pt-2">
|
||||||
|
{[100, 96, 92, 98, 84, 100, 90, 70].map((w, i) => <div key={i} className="h-4 rounded bg-[var(--r-line)]" style={{ width: `${w}%` }} />)}
|
||||||
|
<span className="sr-only">Loading…</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { vi, test, expect } from 'vitest';
|
||||||
|
import ReaderText from './ReaderText.jsx';
|
||||||
|
|
||||||
|
const verses = [
|
||||||
|
{ number: 1, text: 'In the beginning God created the heavens and the earth.' },
|
||||||
|
{ number: 2, text: 'Now the earth was formless and void.' },
|
||||||
|
];
|
||||||
|
const second = [
|
||||||
|
{ number: 1, text: 'In the beginning God created the heavens and the earth.' },
|
||||||
|
{ number: 2, text: 'Now the earth was without shape and empty.' },
|
||||||
|
];
|
||||||
|
const base = {
|
||||||
|
title: 'Genesis 1', translation: 'Berean Standard Bible', translationId: 'BSB', notice: '', verses, loading: false, error: '',
|
||||||
|
layout: 'flow', fontSize: 1, wide: false, bookmarks: {}, bookKey: 'GEN', chapter: 1, selectedVerse: null,
|
||||||
|
onSelectVerse: vi.fn(), renderVerseText: (_n, text) => text, searchLower: '', crossRefs: null, showCrossRefs: false, onSelectionEnd: vi.fn(),
|
||||||
|
};
|
||||||
|
const parallel = (over = {}) => ({ translation: 'NET', name: 'NET Bible', notice: '', verses: second, loading: false, error: '', ...over });
|
||||||
|
|
||||||
|
test('side by side, it says how different the other Bible is and marks the differing words', () => {
|
||||||
|
const { container } = render(<ReaderText {...base} parallel={parallel()} showDiff />);
|
||||||
|
expect(screen.getByText(/is/, { selector: 'p' })).toHaveTextContent(/NET is \d+% different from BSB in this chapter/);
|
||||||
|
expect(screen.getByText(/1 of 2 verses differ/)).toBeInTheDocument();
|
||||||
|
const marked = [...container.querySelectorAll('.diff-hl')].map((n) => n.textContent);
|
||||||
|
expect(marked).toContain('without shape'); // the other Bible's side
|
||||||
|
expect(screen.getByRole('button', { name: /verse 2/i })).toBeInTheDocument(); // the most different verse
|
||||||
|
expect(screen.getByText(/% different/, { selector: 'span.ml-2' })).toBeInTheDocument(); // per-verse figure
|
||||||
|
});
|
||||||
|
|
||||||
|
test('identical wording is reported as the same words', () => {
|
||||||
|
render(<ReaderText {...base} parallel={parallel({ verses })} showDiff />);
|
||||||
|
expect(screen.getByText(/uses the same words as/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('with highlighting off there is no summary and no marks', () => {
|
||||||
|
const { container } = render(<ReaderText {...base} parallel={parallel()} showDiff={false} />);
|
||||||
|
expect(screen.queryByText(/different from BSB/)).not.toBeInTheDocument();
|
||||||
|
expect(container.querySelectorAll('.diff-hl')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('while the other Bible loads there is no comparison yet, and the first Bible still shows', () => {
|
||||||
|
render(<ReaderText {...base} parallel={parallel({ verses: [], loading: true })} showDiff />);
|
||||||
|
expect(screen.queryByText(/different from BSB/)).not.toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText(/In the beginning God created/).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the left Bible gets its own differing words passed to the verse renderer', () => {
|
||||||
|
const renderVerseText = vi.fn((_n, text) => text);
|
||||||
|
render(<ReaderText {...base} renderVerseText={renderVerseText} parallel={parallel()} showDiff />);
|
||||||
|
const call = renderVerseText.mock.calls.find(([n]) => n === 2);
|
||||||
|
expect(call[3].map((r) => verses[1].text.slice(r.start, r.end))).toEqual(['formless', 'void']);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /verse 2/i })); // jumping must not throw even if the element is absent
|
||||||
|
});
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// Search this chapter, or the whole Bible in the translation being read.
|
||||||
|
// BSB and NET are downloaded once and searched in the browser; ESV, NLT, CSB and
|
||||||
|
// NASB are searched through the server (their licences don't allow a bulk copy).
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import Sheet from './Sheet.jsx';
|
||||||
|
import { SearchIcon } from './icons.jsx';
|
||||||
|
import { bookOptions } from '../../utils/bibleUtils.jsx';
|
||||||
|
|
||||||
|
const nameFor = (abbrev) => bookOptions.find((b) => b.abbrev === abbrev)?.name ?? abbrev;
|
||||||
|
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
|
||||||
|
// Wraps each searched word (quotes ignored) in <mark>.
|
||||||
|
function highlight(text, query) {
|
||||||
|
const words = [...new Set(query.toLowerCase().replace(/["“”]/g, ' ').split(/\s+/).filter((w) => w.length > 1))];
|
||||||
|
if (!words.length) return text;
|
||||||
|
const re = new RegExp(`(${words.map(escapeRegExp).join('|')})`, 'gi');
|
||||||
|
return text.split(re).map((part, i) => (i % 2 === 1
|
||||||
|
? <mark key={i} className="rounded bg-[var(--r-hl)] px-0.5 text-[var(--r-text)]">{part}</mark>
|
||||||
|
: part));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchSheet({
|
||||||
|
open, onClose, query, onQuery, scope, onScope, verses, bookName, chapter, currentBook, translation,
|
||||||
|
isLocal, indexStatus, onLoadIndex, bibleMatches, deferredBibleQuery, remote, onJump,
|
||||||
|
}) {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
const chapterMatches = useMemo(
|
||||||
|
() => (scope === 'chapter' && q ? verses.filter((v) => v.text.toLowerCase().includes(q)) : []),
|
||||||
|
[scope, q, verses],
|
||||||
|
);
|
||||||
|
|
||||||
|
const scopeBtn = (id, label, onClick) => (
|
||||||
|
<button type="button" onClick={onClick}
|
||||||
|
className={`px-3 py-1.5 text-xs ${scope === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{label}</button>
|
||||||
|
);
|
||||||
|
const note = (text) => <p className="text-sm text-[var(--r-sub)]">{text}</p>;
|
||||||
|
const resultRow = (r) => (
|
||||||
|
<button key={`${r.bookAbbrev}-${r.chapter}-${r.verse}`} type="button" onClick={() => onJump(r.bookAbbrev, r.chapter, r.verse)}
|
||||||
|
className="block w-full rounded-lg px-2 py-2 text-left text-sm hover:bg-black/5">
|
||||||
|
<span className="mr-1.5 font-medium">{nameFor(r.bookAbbrev)} {r.chapter}:{r.verse}</span>{highlight(r.text, query)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onClose={onClose} title="Search">
|
||||||
|
<div className="mb-3 flex items-center gap-2 rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2">
|
||||||
|
<span className="text-[var(--r-sub)]"><SearchIcon /></span>
|
||||||
|
<input autoFocus value={query} onChange={(e) => onQuery(e.target.value)}
|
||||||
|
placeholder={scope === 'bible' ? `Search the whole Bible (${translation})…` : `Search ${bookName} ${chapter}…`}
|
||||||
|
className="min-w-0 flex-1 bg-transparent text-sm text-[var(--r-text)] outline-none placeholder:text-[var(--r-sub)]" />
|
||||||
|
</div>
|
||||||
|
<div className="mb-3 inline-flex overflow-hidden rounded-lg border border-[var(--r-line)]">
|
||||||
|
{scopeBtn('chapter', 'This chapter', () => onScope('chapter'))}
|
||||||
|
{scopeBtn('bible', `Whole Bible (${translation})`, () => { onScope('bible'); if (isLocal && indexStatus === 'idle') onLoadIndex(); })}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{scope === 'chapter' && (
|
||||||
|
!q ? note('Type a word to find it in this chapter.')
|
||||||
|
: chapterMatches.length === 0 ? note(`No verses match “${query}”.`)
|
||||||
|
: chapterMatches.map((v) => (
|
||||||
|
<button key={v.number} type="button" onClick={() => onJump(currentBook, chapter, v.number)}
|
||||||
|
className="block w-full rounded-lg px-2 py-2 text-left text-sm hover:bg-black/5">
|
||||||
|
<span className="mr-1.5 font-medium">{v.number}</span>{highlight(v.text, query)}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{scope === 'bible' && isLocal && (
|
||||||
|
<>
|
||||||
|
{indexStatus === 'loading' && note('Loading the full Bible for search. This happens once per visit (about 7 MB).')}
|
||||||
|
{indexStatus === 'error' && <p className="text-sm text-[var(--r-danger,#b42318)]">Couldn’t load the full Bible. <button type="button" onClick={onLoadIndex} className="underline">Try again</button></p>}
|
||||||
|
{indexStatus === 'ready' && (!deferredBibleQuery
|
||||||
|
? note('Type a word to search all 66 books.')
|
||||||
|
: bibleMatches.length === 0
|
||||||
|
? note(`No verses match “${query}”.`)
|
||||||
|
: (
|
||||||
|
<>
|
||||||
|
<p className="mb-2 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">
|
||||||
|
{bibleMatches.length} match{bibleMatches.length === 1 ? '' : 'es'}{bibleMatches.length > 100 ? ' (showing first 100)' : ''}
|
||||||
|
</p>
|
||||||
|
{bibleMatches.slice(0, 100).map(resultRow)}
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{scope === 'bible' && !isLocal && (
|
||||||
|
<>
|
||||||
|
{remote.tooShort && note('Type at least two letters. Use quotes for an exact phrase, like “good shepherd”.')}
|
||||||
|
{!remote.tooShort && remote.error && <p className="text-sm text-[var(--r-danger,#b42318)]">{remote.error}</p>}
|
||||||
|
{!remote.tooShort && !remote.error && remote.loading && remote.results.length === 0 && note('Searching…')}
|
||||||
|
{!remote.tooShort && !remote.error && !remote.loading && remote.total === 0 && remote.page > 0 && note(`No verses match “${query}”.`)}
|
||||||
|
{remote.results.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p className="mb-2 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">
|
||||||
|
{remote.total} match{remote.total === 1 ? '' : 'es'} in {translation}
|
||||||
|
</p>
|
||||||
|
{remote.results.map(resultRow)}
|
||||||
|
{remote.hasMore && (
|
||||||
|
<button type="button" onClick={remote.loadMore} disabled={remote.loading}
|
||||||
|
className="mt-2 w-full rounded-lg border border-[var(--r-line)] py-2 text-sm text-[var(--r-sub)] hover:bg-black/5 disabled:opacity-50">
|
||||||
|
{remote.loading ? 'Loading…' : 'Show more'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { test, expect, vi } from 'vitest';
|
||||||
|
import SearchSheet from './SearchSheet.jsx';
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
open: true, onClose: () => {}, query: 'shepherd', onQuery: () => {}, scope: 'bible', onScope: () => {},
|
||||||
|
verses: [], bookName: 'John', chapter: 3, currentBook: 'JHN', translation: 'NLT', isLocal: false,
|
||||||
|
indexStatus: 'idle', onLoadIndex: () => {}, bibleMatches: [], deferredBibleQuery: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
test('server-searched translations list results with highlights and jump on tap', () => {
|
||||||
|
const onJump = vi.fn();
|
||||||
|
const remote = {
|
||||||
|
tooShort: false, loading: false, error: '', page: 1, total: 2, hasMore: true, loadMore: vi.fn(),
|
||||||
|
results: [{ bookAbbrev: 'GEN', chapter: 4, verse: 2, text: 'Abel became a shepherd.' }, { bookAbbrev: 'PSA', chapter: 23, verse: 1, text: 'The Lord is my shepherd.' }],
|
||||||
|
};
|
||||||
|
render(<SearchSheet {...base} remote={remote} onJump={onJump} />);
|
||||||
|
expect(screen.getByText('2 matches in NLT')).toBeTruthy();
|
||||||
|
expect(screen.getAllByText('shepherd')[0].tagName).toBe('MARK');
|
||||||
|
fireEvent.click(screen.getByText(/Genesis 4:2/));
|
||||||
|
expect(onJump).toHaveBeenCalledWith('GEN', 4, 2);
|
||||||
|
fireEvent.click(screen.getByText('Show more'));
|
||||||
|
expect(remote.loadMore).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows the server error, e.g. a rate limit', () => {
|
||||||
|
const remote = { tooShort: false, loading: false, error: 'ESV rate limit reached. Try again in a minute.', page: 0, total: 0, hasMore: false, loadMore: () => {}, results: [] };
|
||||||
|
render(<SearchSheet {...base} translation="ESV" remote={remote} onJump={() => {}} />);
|
||||||
|
expect(screen.getByText(/rate limit/)).toBeTruthy();
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// "Aa" sheet: text size, typeface, spacing, theme, how verses are laid out, and a second Bible
|
||||||
|
// to read beside the first.
|
||||||
|
import Sheet from './Sheet.jsx';
|
||||||
|
import { READER_THEMES } from './readerThemes.js';
|
||||||
|
|
||||||
|
const SIZES = [['S', 0.875], ['M', 1], ['L', 1.125], ['XL', 1.25], ['XXL', 1.5]];
|
||||||
|
|
||||||
|
function Segmented({ options, value, onChange, label }) {
|
||||||
|
return (
|
||||||
|
<div role="group" aria-label={label} className="flex overflow-hidden rounded-xl border border-[var(--r-line)]">
|
||||||
|
{options.map(([id, text]) => (
|
||||||
|
<button key={String(id)} type="button" onClick={() => onChange(id)} aria-pressed={value === id}
|
||||||
|
className={`flex-1 px-3 py-2 text-sm ${value === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>
|
||||||
|
{text}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsSheet({
|
||||||
|
open, onClose, fontSize, onFontSize, theme, onTheme, layout, onLayout, wide, onWide,
|
||||||
|
font, onFont, spacing, onSpacing, verseNumbers, onVerseNumbers,
|
||||||
|
}) {
|
||||||
|
const row = 'mb-4';
|
||||||
|
const label = 'mb-1.5 block text-xs text-[var(--r-sub)]';
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onClose={onClose} title="Reading settings">
|
||||||
|
<div className={row}>
|
||||||
|
<span className={label}>Text size</span>
|
||||||
|
<Segmented label="Text size" options={SIZES.map(([l, s]) => [s, l])} value={fontSize} onChange={onFontSize} />
|
||||||
|
</div>
|
||||||
|
<div className={row}>
|
||||||
|
<span className={label}>Typeface</span>
|
||||||
|
<Segmented label="Typeface" options={[['serif', 'Serif'], ['sans', 'Sans'], ['easy', 'Easy to read']]} value={font} onChange={onFont} />
|
||||||
|
</div>
|
||||||
|
<div className={row}>
|
||||||
|
<span className={label}>Line spacing</span>
|
||||||
|
<Segmented label="Line spacing" options={[['snug', 'Snug'], ['normal', 'Normal'], ['airy', 'Airy']]} value={spacing} onChange={onSpacing} />
|
||||||
|
</div>
|
||||||
|
<div className={row}>
|
||||||
|
<span className={label}>Theme</span>
|
||||||
|
<Segmented label="Theme" options={Object.entries(READER_THEMES).map(([id, t]) => [id, t.label])} value={theme} onChange={onTheme} />
|
||||||
|
</div>
|
||||||
|
<div className={row}>
|
||||||
|
<span className={label}>Verses</span>
|
||||||
|
<Segmented label="Verse layout" options={[['flow', 'Flowing'], ['verses', 'One per line']]} value={layout} onChange={onLayout} />
|
||||||
|
</div>
|
||||||
|
<div className={row}>
|
||||||
|
<span className={label}>Verse numbers</span>
|
||||||
|
<Segmented label="Verse numbers" options={[[true, 'Show'], [false, 'Hide']]} value={verseNumbers} onChange={onVerseNumbers} />
|
||||||
|
</div>
|
||||||
|
<div className={row}>
|
||||||
|
<span className={label}>Width</span>
|
||||||
|
<Segmented label="Reading width" options={[[false, 'Column'], [true, 'Two columns']]} value={wide} onChange={onWide} />
|
||||||
|
</div>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// Slide-up sheet used for every reader tool. `modal` sheets dim the page and
|
||||||
|
// close on outside tap; non-modal ones (the verse sheet) leave the text
|
||||||
|
// tappable. With `pane`, screens >= lg show it as a right-hand side pane
|
||||||
|
// instead of a bottom sheet (tablet landscape / desktop).
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { CloseIcon } from './icons.jsx';
|
||||||
|
|
||||||
|
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||||
|
|
||||||
|
export default function Sheet({ open, onClose, title, label, headerRight, modal = true, pane = false, children }) {
|
||||||
|
const dialogRef = useRef(null);
|
||||||
|
const onCloseRef = useRef(onClose);
|
||||||
|
onCloseRef.current = onClose;
|
||||||
|
|
||||||
|
// Modal sheets behave like dialogs: focus moves in, Tab stays inside, Escape closes, and focus
|
||||||
|
// returns to whatever opened the sheet. (The verse pane is not modal, so it leaves focus alone.)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !modal) return undefined;
|
||||||
|
const opener = document.activeElement;
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (dialog && !dialog.contains(document.activeElement)) dialog.focus({ preventScroll: true });
|
||||||
|
const onKey = (e) => {
|
||||||
|
if (e.key === 'Escape') { e.stopPropagation(); onCloseRef.current?.(); return; }
|
||||||
|
if (e.key !== 'Tab' || !dialog) return;
|
||||||
|
const items = [...dialog.querySelectorAll(FOCUSABLE)].filter((el) => el.offsetParent !== null || el === document.activeElement);
|
||||||
|
if (items.length === 0) { e.preventDefault(); return; }
|
||||||
|
const first = items[0];
|
||||||
|
const last = items[items.length - 1];
|
||||||
|
if (e.shiftKey && (document.activeElement === first || document.activeElement === dialog)) { e.preventDefault(); last.focus(); }
|
||||||
|
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
if (opener instanceof HTMLElement && document.contains(opener)) opener.focus({ preventScroll: true });
|
||||||
|
};
|
||||||
|
}, [open, modal]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{modal && <div className="fixed inset-0 z-40 bg-black/30" onClick={onClose} aria-hidden="true" />}
|
||||||
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal={modal ? 'true' : undefined}
|
||||||
|
aria-label={typeof title === 'string' ? title : label}
|
||||||
|
tabIndex={-1}
|
||||||
|
className={`fixed inset-x-0 bottom-0 z-50 flex max-h-[72vh] flex-col rounded-t-2xl focus:outline-none border-t border-[var(--r-line)] bg-[var(--r-bar)] text-[var(--r-text)] font-sans ${
|
||||||
|
pane ? 'lg:inset-x-auto lg:bottom-0 lg:right-0 lg:top-12 lg:max-h-none lg:w-[24rem] lg:rounded-none lg:border-l lg:border-t-0' : 'sm:left-1/2 sm:right-auto sm:w-[36rem] sm:-translate-x-1/2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="mx-auto mt-2 h-1 w-9 shrink-0 rounded-full bg-[var(--r-line)] lg:hidden" />
|
||||||
|
<div className="flex items-center justify-between gap-2 px-4 pb-2 pt-2">
|
||||||
|
<div className="min-w-0 truncate text-sm font-medium">{title}</div>
|
||||||
|
<div className="flex items-center gap-1 text-[var(--r-sub)]">
|
||||||
|
{headerRight}
|
||||||
|
<button type="button" onClick={onClose} aria-label="Close" className="rounded-lg p-1.5 hover:bg-black/5">
|
||||||
|
<CloseIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-6">{children}</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
// The single home for per-verse tools: original words, cross-references,
|
||||||
|
// commentary, and people & places, plus bookmark / copy.
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Sheet from './Sheet.jsx';
|
||||||
|
import { CommentaryTab, PeoplePlacesTab } from '../StudyPanel.jsx';
|
||||||
|
import { BookmarkIcon, CopyIcon } from '../../utils/bibleUtils.jsx';
|
||||||
|
import { formatCrossRef, speakOriginalWord } from '../../utils/wordStudy.js';
|
||||||
|
import { READER_TRANSLATIONS, fetchChapter } from '../../utils/bibleText.js';
|
||||||
|
import { diffVerses, differenceLabel } from '../../utils/textDiff.js';
|
||||||
|
import { DiffText } from './DiffText.jsx';
|
||||||
|
|
||||||
|
const TABS = [['commentary', 'Commentary'], ['compare', 'Compare'], ['original', 'Original'], ['refs', 'Cross-refs'], ['people', 'People & places']];
|
||||||
|
|
||||||
|
function OriginalTab({ words }) {
|
||||||
|
if (!words) return <p className="text-sm text-[var(--r-sub)]">No original-language data for this verse.</p>;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{words.map((w, i) => {
|
||||||
|
const isHebrew = w.s?.startsWith('H');
|
||||||
|
const canSpeak = isHebrew || w.s?.startsWith('G');
|
||||||
|
return (
|
||||||
|
<div key={i} className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-2 text-center" style={{ minWidth: '4.5rem', maxWidth: '9rem' }}>
|
||||||
|
<div className={`text-lg leading-tight ${isHebrew ? 'font-serif' : ''}`} dir={isHebrew ? 'rtl' : 'ltr'}>{w.o}</div>
|
||||||
|
<div className="mt-0.5 text-xs italic text-[var(--r-sub)]">{w.t}</div>
|
||||||
|
<div className="mt-1 text-xs font-medium">{w.g}</div>
|
||||||
|
{w.p && <div className="mt-0.5 text-[10px] leading-tight text-[var(--r-sub)]">{w.p}</div>}
|
||||||
|
{w.s && <div className="mt-0.5 text-[10px] text-[var(--r-sub)]">{w.s}</div>}
|
||||||
|
{canSpeak && (
|
||||||
|
<button type="button" onClick={() => speakOriginalWord(w.o, w.s)}
|
||||||
|
className="mt-1.5 rounded-lg bg-[var(--r-bar)] px-2 py-0.5 text-[11px] hover:bg-black/5"
|
||||||
|
title={`Pronounce in ${isHebrew ? 'Hebrew' : 'Greek'}`}>Speak</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RefsTab({ refs, loading, onJump }) {
|
||||||
|
if (loading && !refs) return <p className="text-sm text-[var(--r-sub)]">Loading…</p>;
|
||||||
|
if (!refs?.length) return <p className="text-sm text-[var(--r-sub)]">No cross-references for this verse.</p>;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{refs.slice(0, 20).map((ref, i) => (
|
||||||
|
<button key={i} type="button" onClick={() => onJump(ref.book, ref.chapter, ref.verse)}
|
||||||
|
className="rounded-full border border-[var(--r-line)] px-2.5 py-1 text-xs hover:bg-black/5">
|
||||||
|
{formatCrossRef(ref)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// This verse in every translation, one under the other. Each Bible loads the chapter it is in
|
||||||
|
// (BSB and NET come from the free source; the rest through the server) and shows this verse.
|
||||||
|
function CompareTab({ book, chapter, verse, current }) {
|
||||||
|
const [rows, setRows] = useState({}); // id -> { text } | { error } | undefined while loading
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
setRows({});
|
||||||
|
READER_TRANSLATIONS.forEach((t) => {
|
||||||
|
fetchChapter(t.id, book, chapter, { signal: controller.signal })
|
||||||
|
.then(({ verses }) => {
|
||||||
|
const text = verses.find((v) => v.number === verse)?.text;
|
||||||
|
setRows((r) => ({ ...r, [t.id]: text ? { text } : { error: 'No verse here in this translation.' } }));
|
||||||
|
})
|
||||||
|
.catch((err) => { if (!controller.signal.aborted) setRows((r) => ({ ...r, [t.id]: { error: err.message || 'Unavailable.' } })); });
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [book, chapter, verse]);
|
||||||
|
|
||||||
|
const mine = rows[current]?.text;
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{READER_TRANSLATIONS.map((t) => {
|
||||||
|
const row = rows[t.id];
|
||||||
|
// How this wording differs from the Bible being read, word by word
|
||||||
|
const d = row?.text && mine && t.id !== current ? diffVerses(mine, row.text) : null;
|
||||||
|
return (
|
||||||
|
<div key={t.id}>
|
||||||
|
<p className="mb-0.5 flex items-center gap-2 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">
|
||||||
|
<span>{t.id}{t.id === current ? ' · reading' : ''}</span>
|
||||||
|
{d && <span className="normal-case tracking-normal">{differenceLabel(d.similarity)}</span>}
|
||||||
|
</p>
|
||||||
|
{!row ? <p className="text-sm text-[var(--r-sub)]">Loading…</p>
|
||||||
|
: row.error ? <p className="text-sm text-[var(--r-sub)]">{row.error}</p>
|
||||||
|
: <p className="font-serif text-[15px] leading-relaxed"><DiffText text={row.text} ranges={d?.b} /></p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<p className="border-t border-[var(--r-line)] pt-2 text-[10px] leading-snug text-[var(--r-sub)]">
|
||||||
|
{mine ? <><span className="diff-hl">Marked words</span> aren’t in the {current} wording. </> : null}
|
||||||
|
Copyright notices for each translation appear at the foot of the chapter when you read it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VerseSheet({
|
||||||
|
verse, book, bookName, chapter, translation, words, crossRefs, crossRefsLoading, onLoadCrossRefs,
|
||||||
|
bookmarkColor, onToggleBookmark, onCycleColor, onJump, onClose,
|
||||||
|
}) {
|
||||||
|
const [tab, setTab] = useState('commentary');
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
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;
|
||||||
|
|
||||||
|
const copy = () => {
|
||||||
|
navigator.clipboard?.writeText(`${bookName} ${chapter}:${verse.number} ${translation} — ${verse.text}`);
|
||||||
|
setCopied(true);
|
||||||
|
window.setTimeout(() => setCopied(false), 1500);
|
||||||
|
};
|
||||||
|
const actionBtn = 'rounded-lg p-1.5 hover:bg-black/5';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet
|
||||||
|
open modal={false} pane onClose={onClose}
|
||||||
|
title={`${bookName} ${chapter}:${verse.number}`}
|
||||||
|
headerRight={(
|
||||||
|
<>
|
||||||
|
<button type="button" onClick={onToggleBookmark} className={`${actionBtn} ${bookmarkColor ? 'text-[var(--r-accent)]' : ''}`} aria-label={bookmarkColor ? 'Remove bookmark' : 'Bookmark verse'}>
|
||||||
|
<BookmarkIcon filled={!!bookmarkColor} />
|
||||||
|
</button>
|
||||||
|
{bookmarkColor && (
|
||||||
|
<button type="button" onClick={onCycleColor} className={actionBtn} aria-label="Change bookmark colour">
|
||||||
|
<span className="block h-4 w-4 rounded-full border border-black/10" style={{ backgroundColor: bookmarkColor }} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="button" onClick={copy} className={actionBtn} aria-label="Copy verse">
|
||||||
|
{copied ? <span className="px-1 text-xs">Copied</span> : <CopyIcon />}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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)}
|
||||||
|
className={`shrink-0 rounded-full border px-3 py-1 text-xs ${tab === id ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{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 === 'people' && <PeoplePlacesTab book={book} chapter={chapter} verse={verse.number} />}
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Small stroke icons for the reader chrome (currentColor, 20px grid).
|
||||||
|
const base = { viewBox: '0 0 20 20', width: 20, height: 20, fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true };
|
||||||
|
|
||||||
|
export const ChevronLeft = () => <svg {...base}><path d="M12 4l-6 6 6 6" /></svg>;
|
||||||
|
export const ChevronRight = () => <svg {...base}><path d="M8 4l6 6-6 6" /></svg>;
|
||||||
|
export const ChevronDown = ({ size = 14 }) => <svg {...base} width={size} height={size}><path d="M5 8l5 5 5-5" /></svg>;
|
||||||
|
export const SearchIcon = () => <svg {...base}><circle cx="9" cy="9" r="5.5" /><path d="M13.5 13.5L17 17" /></svg>;
|
||||||
|
export const CloseIcon = () => <svg {...base}><path d="M5 5l10 10M15 5L5 15" /></svg>;
|
||||||
|
export const LinkIcon = () => <svg {...base}><path d="M8.5 11.5a3 3 0 004.2 0l2.3-2.3a3 3 0 00-4.2-4.2L9.7 6.1" /><path d="M11.5 8.5a3 3 0 00-4.2 0L5 10.8A3 3 0 009.2 15l1.1-1.1" /></svg>;
|
||||||
|
export const PenIcon = () => <svg {...base}><path d="M13.5 3.5l3 3L7 16l-4 1 1-4 9.5-9.5z" /></svg>;
|
||||||
|
export const HeadphonesIcon = () => <svg {...base}><path d="M3.5 12V10a6.5 6.5 0 0113 0v2" /><rect x="2.5" y="11.5" width="3.5" height="5" rx="1.2" /><rect x="14" y="11.5" width="3.5" height="5" rx="1.2" /></svg>;
|
||||||
|
export const FocusIcon = () => <svg {...base}><path d="M3 7V4h3M17 7V4h-3M3 13v3h3M17 13v3h-3" /></svg>;
|
||||||
|
export const 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>;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Reader colour themes, applied as CSS variables on the reader root so every
|
||||||
|
// reader component can use bg-[var(--r-bg)] etc. and stay in sync.
|
||||||
|
export const READER_THEMES = {
|
||||||
|
paper: {
|
||||||
|
label: 'Paper',
|
||||||
|
vars: { '--r-bg': '#faf7f0', '--r-text': '#2b2620', '--r-sub': '#6b6458', '--r-accent': '#a84e27', '--r-bar': '#f1ece0', '--r-line': '#e0d8c6', '--r-hl': '#f6e7a8', '--r-on': '#ffffff', '--r-danger': '#b42318' },
|
||||||
|
},
|
||||||
|
sepia: {
|
||||||
|
label: 'Sepia',
|
||||||
|
vars: { '--r-bg': '#f1e4c8', '--r-text': '#3b2f1c', '--r-sub': '#635539', '--r-accent': '#93471d', '--r-bar': '#e8d8b6', '--r-line': '#d5c197', '--r-hl': '#e9cf85', '--r-on': '#ffffff', '--r-danger': '#a1281d' },
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
label: 'Dark',
|
||||||
|
vars: { '--r-bg': '#1c1b19', '--r-text': '#e6e1d6', '--r-sub': '#b8b2a4', '--r-accent': '#e0895c', '--r-bar': '#262421', '--r-line': '#3a3733', '--r-hl': '#4a4224', '--r-on': '#1c1b19', '--r-danger': '#f0857a' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const themeVars = (id) => (READER_THEMES[id] ?? READER_THEMES.paper).vars;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
import { READER_THEMES } from './readerThemes.js';
|
||||||
|
|
||||||
|
// WCAG relative luminance and contrast ratio
|
||||||
|
const luminance = (hex) => {
|
||||||
|
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
|
||||||
|
.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
|
||||||
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||||
|
};
|
||||||
|
const contrast = (a, b) => {
|
||||||
|
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
||||||
|
return (hi + 0.05) / (lo + 0.05);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Small text needs 4.5:1. These are the pairings the app actually draws.
|
||||||
|
describe.each(Object.entries(READER_THEMES))('%s theme contrast', (_id, { vars }) => {
|
||||||
|
const v = (name) => vars[`--r-${name}`];
|
||||||
|
test.each([
|
||||||
|
['text', 'bg'], ['text', 'bar'], ['text', 'hl'],
|
||||||
|
['sub', 'bg'], ['sub', 'bar'], ['sub', 'hl'],
|
||||||
|
['accent', 'bg'], ['accent', 'bar'],
|
||||||
|
['danger', 'bg'], ['danger', 'bar'],
|
||||||
|
['on', 'accent'],
|
||||||
|
])('%s on %s is at least 4.5:1', (fg, bg) => {
|
||||||
|
expect(contrast(v(fg), v(bg))).toBeGreaterThanOrEqual(4.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// Settings → "Your data": download everything as one file, restore from one, and save Bibles for offline.
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { useApp } from '../../context/AppContext.js';
|
||||||
|
import { useToast } from '../Toasts.jsx';
|
||||||
|
import { DownloadProgress } from '../OfflineSave.jsx';
|
||||||
|
import { useOfflineSave } from '../../hooks/useOfflineSave.js';
|
||||||
|
import { backupFileName, buildBackup, restoreProjects, validateBackup } from '../../utils/backup.js';
|
||||||
|
import { saveRemoteProject } from '../../syncService.js';
|
||||||
|
|
||||||
|
const btn = 'rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5 disabled:opacity-50';
|
||||||
|
|
||||||
|
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
||||||
|
|
||||||
|
export default function DataSection() {
|
||||||
|
const {
|
||||||
|
authUser, readerBookmarks, readerTextHighlights, readerColorLabels, readerInkByPage, readingPlansState,
|
||||||
|
importReaderMarks, importReadingPlans, refreshProjectIndex,
|
||||||
|
} = useApp();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const offline = useOfflineSave();
|
||||||
|
const fileRef = useRef(null);
|
||||||
|
const [pending, setPending] = useState(null); // { name, backup, summary } waiting for "Restore"
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const download = () => {
|
||||||
|
const backup = buildBackup({
|
||||||
|
email: authUser?.email ?? null,
|
||||||
|
reader: { bookmarks: readerBookmarks, highlights: readerTextHighlights, colorLabels: readerColorLabels, ink: readerInkByPage },
|
||||||
|
readingPlans: readingPlansState,
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' }));
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = backupFileName();
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
toast(`Backup downloaded: ${plural(backup.projects.length, 'study', 'studies')} and your reading marks`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const choose = async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = '';
|
||||||
|
setError('');
|
||||||
|
setPending(null);
|
||||||
|
if (!file) return;
|
||||||
|
let data;
|
||||||
|
try { data = JSON.parse(await file.text()); } catch { setError('That file couldn’t be read as a backup.'); return; }
|
||||||
|
const checked = validateBackup(data);
|
||||||
|
if (!checked.ok) { setError(checked.error); return; }
|
||||||
|
setPending({ name: file.name, ...checked });
|
||||||
|
};
|
||||||
|
|
||||||
|
const restore = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
const { backup } = pending;
|
||||||
|
const written = restoreProjects(backup.projects);
|
||||||
|
importReaderMarks(backup.reader);
|
||||||
|
importReadingPlans(backup.readingPlans);
|
||||||
|
refreshProjectIndex();
|
||||||
|
// Studies restored here also go to the account, so they follow you to other devices
|
||||||
|
if (authUser) await Promise.all(written.map((p) => saveRemoteProject(p)));
|
||||||
|
setBusy(false);
|
||||||
|
setPending(null);
|
||||||
|
toast(`Restored ${plural(written.length, 'study', 'studies')}, plus your bookmarks, highlights and plans`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const s = pending?.summary;
|
||||||
|
return (
|
||||||
|
<section id="backup" aria-label="Your data" className="scroll-mt-16 space-y-5 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-medium">Your data</h2>
|
||||||
|
<p className="mt-1 text-sm text-[var(--r-sub)]">Everything you’ve made — studies and notes, bookmarks, highlights, ink and reading plans — in one file you keep.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<button type="button" onClick={download} className={btn}>Download backup</button>
|
||||||
|
<button type="button" onClick={() => fileRef.current?.click()} className={btn}>Restore from a backup…</button>
|
||||||
|
<input ref={fileRef} type="file" accept="application/json,.json" onChange={choose} className="hidden" aria-label="Backup file" />
|
||||||
|
</div>
|
||||||
|
{error && <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{error}</p>}
|
||||||
|
{pending && (
|
||||||
|
<div className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-4 text-sm">
|
||||||
|
<p className="font-medium">{pending.name}</p>
|
||||||
|
<p className="mt-1 text-[var(--r-sub)]">
|
||||||
|
Contains {plural(s.projects, 'study', 'studies')}, {plural(s.bookmarks, 'bookmark')}, {plural(s.highlights, 'highlight')}, {plural(s.inkPages, 'ink page')} and {plural(s.plans, 'reading plan')}.
|
||||||
|
Restoring adds these to what you have — nothing is deleted, and a study on this device is only replaced if the backup’s copy is newer.
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<button type="button" onClick={restore} disabled={busy} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50">{busy ? 'Restoring…' : 'Restore'}</button>
|
||||||
|
<button type="button" onClick={() => setPending(null)} className={btn}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="border-t border-[var(--r-line)] pt-4">
|
||||||
|
<h3 className="text-sm font-medium">Read offline</h3>
|
||||||
|
<p className="mb-3 mt-1 text-sm text-[var(--r-sub)]">Chapters you read are kept automatically. To read anywhere, save a whole Bible while you’re connected (about 4 MB). The ESV, NLT, CSB and NASB can’t be saved: their licences don’t allow it.</p>
|
||||||
|
{offline.job ? <DownloadProgress job={offline.job} onCancel={offline.cancel} /> : (
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{['BSB', 'NET'].map((t) => (
|
||||||
|
<button key={t} type="button" onClick={() => offline.saveBible(t)} className={btn}>
|
||||||
|
{(offline.saved[t] ?? []).length >= 66 ? `✓ The whole ${t} is saved` : `Save the whole ${t}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Commentary for the chunk's verse range, from seven open sources. Some sources
|
||||||
|
// comment on whole passages, so when nothing starts inside the range we show the
|
||||||
|
// closest earlier entry. "Add to notes" quotes a paragraph, with its citation, into
|
||||||
|
// the chunk's background notes.
|
||||||
|
import { COMMENTARIES } from '../../hooks/useVerseStudy.js';
|
||||||
|
import { formatChunkReference } from '../../utils/bibleUtils.jsx';
|
||||||
|
|
||||||
|
export default function CommentaryChunkTab({ resources, chunk, project, chapterIndex, updateChunk, notify }) {
|
||||||
|
const { commentarySource, setCommentarySource, commentaryData, commentaryLoading, commentaryError } = resources;
|
||||||
|
const source = COMMENTARIES.find((c) => c.id === commentarySource) ?? COMMENTARIES[0];
|
||||||
|
const items = (commentaryData?.chapter?.content ?? []).filter((i) => i.type === 'verse').sort((a, b) => a.number - b.number);
|
||||||
|
let entries = items.filter((i) => i.number >= chunk.startVerse && i.number <= chunk.endVerse);
|
||||||
|
let earlier = false;
|
||||||
|
if (entries.length === 0) {
|
||||||
|
const before = [...items].reverse().find((i) => i.number <= chunk.startVerse);
|
||||||
|
if (before) { entries = [before]; earlier = true; }
|
||||||
|
}
|
||||||
|
const ref = formatChunkReference(project, chapterIndex, chunk, '–');
|
||||||
|
|
||||||
|
const quote = (text, verse) => {
|
||||||
|
const citation = `${text} (${source.name}, ${ref.split(':')[0]}:${verse})`;
|
||||||
|
const existing = chunk.generalNotes ?? '';
|
||||||
|
updateChunk(chunk.id, { generalNotes: existing ? `${existing}\n\n${citation}` : citation });
|
||||||
|
notify('Added to background notes.', 1600);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<select aria-label="Commentary source" value={source.id} onChange={(e) => setCommentarySource(e.target.value)}
|
||||||
|
className="mb-3 w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1.5 text-sm text-[var(--r-text)]">
|
||||||
|
{COMMENTARIES.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
|
</select>
|
||||||
|
{commentaryLoading && <p className="text-sm text-[var(--r-sub)]">Loading commentary…</p>}
|
||||||
|
{commentaryError && <p className="text-sm text-[var(--r-sub)]">{commentaryError}</p>}
|
||||||
|
{!commentaryLoading && !commentaryError && commentaryData && entries.length === 0 && (
|
||||||
|
<p className="text-sm text-[var(--r-sub)]">No commentary found for {ref}.</p>
|
||||||
|
)}
|
||||||
|
{!commentaryLoading && !commentaryError && entries.map((v) => (
|
||||||
|
<div key={v.number} className="mb-4">
|
||||||
|
<p className="mb-1 text-xs font-medium text-[var(--r-sub)]">{earlier ? `Notes on the passage starting at verse ${v.number}` : `Verse ${v.number}`}</p>
|
||||||
|
{(v.content ?? []).map((p, i) => {
|
||||||
|
const text = typeof p === 'string' ? p : '';
|
||||||
|
if (!text) return null;
|
||||||
|
return (
|
||||||
|
<div key={i} className="group mb-2">
|
||||||
|
<p className="whitespace-pre-line text-[13px] leading-relaxed">{text}</p>
|
||||||
|
<button type="button" onClick={() => quote(text, v.number)} className="mt-0.5 text-[11px] text-[var(--r-accent)] opacity-70 hover:underline hover:opacity-100">Add to notes</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<p className="mt-2 text-[10px] text-[var(--r-sub)]">{source.credit}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// Cross-references for the chunk: typed, suggested from the Open Cross Ref
|
||||||
|
// dataset, and a preview of each reference's verse text.
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
function RefChip({ label, onRemove, loadVerseText }) {
|
||||||
|
const [text, setText] = useState(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const toggle = () => {
|
||||||
|
setOpen((o) => !o);
|
||||||
|
if (text == null) loadVerseText(label).then((t) => setText(t || 'Verse text unavailable.')).catch(() => setText('Verse text unavailable.'));
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-[var(--r-line)]">
|
||||||
|
<div className="flex items-center justify-between gap-2 px-3 py-1.5">
|
||||||
|
<button type="button" onClick={toggle} aria-expanded={open} className="flex-1 text-left text-sm">{label}</button>
|
||||||
|
<button type="button" onClick={onRemove} aria-label={`Remove ${label}`} className="text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]">×</button>
|
||||||
|
</div>
|
||||||
|
{open && <p className="border-t border-[var(--r-line)] px-3 py-2 font-serif text-[13px] leading-relaxed text-[var(--r-sub)]">{text ?? 'Loading…'}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CrossRefsTab({ crossRefs, chunk }) {
|
||||||
|
const { crossRefInput, setCrossRefInput, suggestingCrossRefs, crossRefSuggestions, addCrossRef, removeCrossRef,
|
||||||
|
suggestCrossRefsForChunk, addSuggestedCrossRef, loadVerseText } = crossRefs;
|
||||||
|
const refs = chunk.crossReferences ?? [];
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input value={crossRefInput} onChange={(e) => setCrossRefInput(e.target.value)} placeholder="e.g. John 1:1 or Rom 3:23"
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addCrossRef(chunk.id); } }}
|
||||||
|
className="min-w-0 flex-1 rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" />
|
||||||
|
<button type="button" onClick={() => addCrossRef(chunk.id)} className="rounded-lg bg-[var(--r-text)] px-3 py-1.5 text-sm text-[var(--r-bg)]">Add</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={() => suggestCrossRefsForChunk(chunk.id)} disabled={suggestingCrossRefs}
|
||||||
|
className="mt-2 rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-sm hover:bg-black/5 disabled:opacity-50">
|
||||||
|
{suggestingCrossRefs ? 'Suggesting…' : 'Suggest from passage'}
|
||||||
|
</button>
|
||||||
|
{crossRefSuggestions.length > 0 && (
|
||||||
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||||
|
{crossRefSuggestions.map((ref) => (
|
||||||
|
<button key={ref} type="button" onClick={() => addSuggestedCrossRef(chunk.id, ref)}
|
||||||
|
className="rounded-full border border-[var(--r-accent)] px-2.5 py-0.5 text-xs text-[var(--r-accent)] hover:bg-black/5">+ {ref}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-3 space-y-1.5">
|
||||||
|
{refs.map((ref) => <RefChip key={ref} label={ref} onRemove={() => removeCrossRef(chunk.id, ref)} loadVerseText={loadVerseText} />)}
|
||||||
|
{refs.length === 0 && <p className="text-sm text-[var(--r-sub)]">No cross-references yet.</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// Observe / Interpret / Apply as an accordion (one open at a time), plus a "More"
|
||||||
|
// area for background notes and tags so the page stays quiet.
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const SECTIONS = [
|
||||||
|
{ field: 'observation', label: 'Observe', hint: 'What does the text say?',
|
||||||
|
placeholder: "What does the text actually say?\n• Who is speaking, and to whom?\n• What key words or phrases repeat?\n• What's the tone, structure, or literary style?" },
|
||||||
|
{ field: 'interpretation', label: 'Interpret', hint: 'What does it mean?',
|
||||||
|
placeholder: "What did this mean to its original audience?\n• What's the historical/cultural context?\n• How does it fit the surrounding argument?\n• What does it reveal about God's character?" },
|
||||||
|
{ field: 'application', label: 'Apply', hint: 'What do I do with it?',
|
||||||
|
placeholder: 'How should this shape your life today?\n• What attitude or action does this call for?\n• Is there a promise to trust or a warning to heed?\n• Who could you share this with?' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const areaCls = 'w-full resize-y rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3.5 py-3 font-serif text-[15px] leading-7 text-[var(--r-text)] placeholder:font-sans placeholder:text-[13px] placeholder:leading-5 placeholder:text-[var(--r-sub)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
|
||||||
|
const words = (s) => (s ?? '').trim().split(/\s+/).filter(Boolean).length;
|
||||||
|
|
||||||
|
export default function NotesPane({ chunk, updateChunk, allTags }) {
|
||||||
|
const [open, setOpen] = useState(() => {
|
||||||
|
try { return localStorage.getItem('studyNotesOpen') || 'observation'; } catch { return 'observation'; }
|
||||||
|
});
|
||||||
|
const [more, setMore] = useState(false);
|
||||||
|
const [tagInput, setTagInput] = useState('');
|
||||||
|
useEffect(() => { setTagInput(''); }, [chunk.id]);
|
||||||
|
useEffect(() => { try { localStorage.setItem('studyNotesOpen', open || ''); } catch { /* storage unavailable */ } }, [open]);
|
||||||
|
|
||||||
|
const tags = chunk.tags ?? [];
|
||||||
|
const addTag = (tag) => {
|
||||||
|
const t = tag.trim();
|
||||||
|
if (!t || tags.some((x) => x.toLowerCase() === t.toLowerCase())) { setTagInput(''); return; }
|
||||||
|
updateChunk(chunk.id, { tags: [...tags, t] });
|
||||||
|
setTagInput('');
|
||||||
|
};
|
||||||
|
const suggestions = allTags.filter((t) => !tags.some((x) => x.toLowerCase() === t.toLowerCase()));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Study notes" className="space-y-2">
|
||||||
|
{SECTIONS.map(({ field, label, hint, placeholder }) => {
|
||||||
|
const isOpen = open === field;
|
||||||
|
const n = words(chunk[field]);
|
||||||
|
return (
|
||||||
|
<div key={field} className={`overflow-hidden rounded-2xl border ${isOpen ? 'border-[var(--r-accent)]' : 'border-[var(--r-line)]'}`}>
|
||||||
|
<button type="button" onClick={() => setOpen(isOpen ? null : field)} aria-expanded={isOpen}
|
||||||
|
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left">
|
||||||
|
<span className="flex items-center gap-2.5">
|
||||||
|
<span aria-hidden="true" className={`h-2.5 w-2.5 rounded-full border ${n ? 'border-[var(--r-accent)] bg-[var(--r-accent)]' : 'border-[var(--r-sub)]'}`} />
|
||||||
|
<span className="text-sm font-medium">{label}</span>
|
||||||
|
<span className="text-xs text-[var(--r-sub)]">{hint}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-[var(--r-sub)]">{n ? `${n} word${n === 1 ? '' : 's'}` : ''}</span>
|
||||||
|
</button>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="px-4 pb-4">
|
||||||
|
<textarea aria-label={label} value={chunk[field] ?? ''} rows={7} placeholder={placeholder}
|
||||||
|
onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })} className={areaCls} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-[var(--r-line)]">
|
||||||
|
<button type="button" onClick={() => setMore((m) => !m)} aria-expanded={more}
|
||||||
|
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm">
|
||||||
|
<span><span className="font-medium">More</span> <span className="text-xs text-[var(--r-sub)]">background{tags.length ? ` · ${tags.length} tag${tags.length === 1 ? '' : 's'}` : ' and tags'}</span></span>
|
||||||
|
<span className="text-xs text-[var(--r-sub)]">{more ? '▾' : '▸'}</span>
|
||||||
|
</button>
|
||||||
|
{more && (
|
||||||
|
<div className="space-y-4 px-4 pb-4">
|
||||||
|
<label className="block text-xs text-[var(--r-sub)]">
|
||||||
|
Background / general notes
|
||||||
|
<textarea value={chunk.generalNotes ?? ''} rows={5} onChange={(e) => updateChunk(chunk.id, { generalNotes: e.target.value })}
|
||||||
|
placeholder="e.g. author, date, audience, historical context, key themes…" className={`mt-1 ${areaCls}`} />
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<p className="mb-1.5 text-xs text-[var(--r-sub)]">Tags</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<span key={tag} className="inline-flex items-center gap-1 rounded-full bg-[var(--r-bar)] px-2.5 py-1 text-xs">
|
||||||
|
{tag}
|
||||||
|
<button type="button" aria-label={`Remove tag ${tag}`} onClick={() => updateChunk(chunk.id, { tags: tags.filter((t) => t !== tag) })} className="text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]">×</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<input value={tagInput} onChange={(e) => setTagInput(e.target.value)} list="study-tag-suggestions"
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addTag(tagInput); } }}
|
||||||
|
placeholder="Add a tag and press Enter"
|
||||||
|
className="min-w-[10rem] flex-1 rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1 text-xs text-[var(--r-text)] focus:outline-none" />
|
||||||
|
</div>
|
||||||
|
{suggestions.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-xs">
|
||||||
|
<span className="text-[var(--r-sub)]">Suggestions:</span>
|
||||||
|
{suggestions.slice(0, 12).map((t) => (
|
||||||
|
<button key={t} type="button" onClick={() => addTag(t)} className="rounded-full border border-[var(--r-line)] px-2.5 py-0.5 text-[var(--r-sub)] hover:bg-black/5">+ {t}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<datalist id="study-tag-suggestions">{allTags.map((t) => <option key={t} value={t} />)}</datalist>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Left rail of the Study stage: every chunk of the project as an outline, with
|
||||||
|
// three dots (Observe / Interpret / Apply) showing which notes are written.
|
||||||
|
import { formatChunkReference } from '../../utils/bibleUtils.jsx';
|
||||||
|
|
||||||
|
const filled = (s) => (s ?? '').trim().length > 0;
|
||||||
|
|
||||||
|
export default function OutlineRail({ project, selectedId, onSelect, onEditOutline }) {
|
||||||
|
return (
|
||||||
|
<nav aria-label="Chunks" className="flex h-full min-h-0 flex-col">
|
||||||
|
<div className="flex items-center justify-between px-3 pb-2 pt-3">
|
||||||
|
<span className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Chunks</span>
|
||||||
|
<button type="button" onClick={onEditOutline} className="text-[11px] text-[var(--r-accent)] hover:underline">Edit</button>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-4">
|
||||||
|
{project.chapters.map((ch, chapterIndex) => (
|
||||||
|
<div key={`${ch.bookAbbrev}-${ch.chapter}`} className="mb-3">
|
||||||
|
{project.chapters.length > 1 && <p className="px-2 pb-1 text-[11px] text-[var(--r-sub)]">{ch.book} {ch.chapter}</p>}
|
||||||
|
{ch.chunks.length === 0 && <p className="px-2 text-xs text-[var(--r-sub)]">No chunks in this chapter.</p>}
|
||||||
|
{ch.chunks.map((chunk) => {
|
||||||
|
const selected = chunk.id === selectedId;
|
||||||
|
const first = ch.verses.find((v) => v.number === chunk.startVerse)?.text ?? '';
|
||||||
|
return (
|
||||||
|
<button key={chunk.id} type="button" onClick={() => onSelect(chunk.id)} aria-current={selected ? 'true' : undefined}
|
||||||
|
className={`mb-1 block w-full rounded-xl border px-2.5 py-2 text-left transition ${selected ? 'border-[var(--r-line)] bg-[var(--r-bg)]' : 'border-transparent hover:bg-black/5'}`}>
|
||||||
|
<span className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-[13px] font-medium">{formatChunkReference(project, chapterIndex, chunk, '–')}</span>
|
||||||
|
<span className="flex gap-1" aria-hidden="true">
|
||||||
|
{['observation', 'interpretation', 'application'].map((f) => (
|
||||||
|
<span key={f} className={`h-2 w-2 rounded-full border ${filled(chunk[f]) ? 'border-[var(--r-accent)] bg-[var(--r-accent)]' : 'border-[var(--r-sub)]'}`} />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{chunk.episodeTitle && <span className="mt-0.5 block truncate text-[11px] text-[var(--r-accent)]">{chunk.episodeNumber ? `${chunk.episodeNumber} · ` : ''}{chunk.episodeTitle}</span>}
|
||||||
|
<span className="mt-0.5 block truncate font-serif text-xs text-[var(--r-sub)]">{first}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// Themed frame for pages outside a study project (Home, Import, Settings, Admin):
|
||||||
|
// a calm app bar (brand, three destinations, account menu) and a page heading row.
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useApp } from '../../context/AppContext.js';
|
||||||
|
import { themeVars, READER_THEMES } from '../reader/readerThemes.js';
|
||||||
|
import { ChevronLeft, SearchIcon } from '../reader/icons.jsx';
|
||||||
|
|
||||||
|
// `short` is what a phone shows; the full label is still the button's accessible name.
|
||||||
|
const NAV = [
|
||||||
|
{ id: 'studies', label: 'Studies', short: 'Studies' },
|
||||||
|
{ id: 'reader', label: 'Read Bible', short: 'Bible' },
|
||||||
|
{ id: 'plans', label: 'Reading Plans', short: 'Plans' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Avatar button with a dropdown: who you are, the theme, Settings, Admin, Log out.
|
||||||
|
function AccountMenu() {
|
||||||
|
const { authUser, readerTheme, setReaderTheme, setCurrentPage, logout } = useApp();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return undefined;
|
||||||
|
const onDown = (e) => { if (!ref.current?.contains(e.target)) setOpen(false); };
|
||||||
|
const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
|
||||||
|
document.addEventListener('mousedown', onDown);
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey); };
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const go = (page) => { setOpen(false); setCurrentPage(page); };
|
||||||
|
const item = 'block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-black/5';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
<button type="button" onClick={() => setOpen((o) => !o)} aria-label="Account menu" aria-expanded={open}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-full border border-[var(--r-line)] bg-[var(--r-bg)] text-xs font-medium uppercase hover:bg-black/5">
|
||||||
|
{authUser?.email?.[0] ?? '·'}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div role="menu" className="absolute right-0 top-full z-40 mt-2 w-60 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-2 shadow-xl">
|
||||||
|
{authUser && <p className="truncate px-3 pb-1 pt-2 text-xs text-[var(--r-sub)]">{authUser.email}</p>}
|
||||||
|
<div className="px-3 pb-2 pt-1">
|
||||||
|
<p className="mb-1.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Theme</p>
|
||||||
|
<div className="flex overflow-hidden rounded-lg border border-[var(--r-line)]">
|
||||||
|
{Object.entries(READER_THEMES).map(([id, t]) => (
|
||||||
|
<button key={id} type="button" onClick={() => setReaderTheme(id)} aria-pressed={readerTheme === id}
|
||||||
|
className={`flex-1 px-2 py-1.5 text-xs ${readerTheme === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{t.label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{authUser && (
|
||||||
|
<div className="border-t border-[var(--r-line)] pt-1">
|
||||||
|
<button type="button" role="menuitem" onClick={() => go('settings')} className={item}>Settings</button>
|
||||||
|
{authUser.isAdmin && <button type="button" role="menuitem" onClick={() => go('admin')} className={item}>Admin</button>}
|
||||||
|
<button type="button" role="menuitem" onClick={() => { setOpen(false); logout(); }} className={item}>Log out</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PageShell({ title, onBack, actions, active = null, maxWidth = 'max-w-6xl', children }) {
|
||||||
|
const { readerTheme, goHome, goStudies, openBibleReader, openReadingPlans, openPalette } = useApp();
|
||||||
|
const go = { studies: goStudies, reader: openBibleReader, plans: openReadingPlans };
|
||||||
|
return (
|
||||||
|
<div className="min-h-dvh bg-[var(--r-bg)] font-sans text-[var(--r-text)]" style={themeVars(readerTheme)}>
|
||||||
|
<a href="#content" className="skip-link">Skip to content</a>
|
||||||
|
<header className="safe-top sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)]">
|
||||||
|
<div className={`mx-auto flex h-12 ${maxWidth} items-center gap-2 px-3 sm:gap-4 sm:px-4`}>
|
||||||
|
<button type="button" onClick={goHome} className="flex shrink-0 items-center gap-2" aria-label="Bible Study home">
|
||||||
|
<img src="/logo.svg" alt="" className="h-6 w-6" />
|
||||||
|
<span className="hidden text-sm font-medium sm:inline">Bible Study</span>
|
||||||
|
</button>
|
||||||
|
<nav aria-label="Main" className="flex items-center gap-0.5">
|
||||||
|
{NAV.map((n) => (
|
||||||
|
<button key={n.id} type="button" onClick={go[n.id]} aria-current={active === n.id ? 'page' : undefined} aria-label={n.label}
|
||||||
|
className={`whitespace-nowrap rounded-full px-2.5 py-1.5 text-sm sm:px-3 ${active === n.id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>
|
||||||
|
<span className="sm:hidden" aria-hidden="true">{n.short}</span><span className="hidden sm:inline" aria-hidden="true">{n.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
{openPalette && (
|
||||||
|
<button type="button" onClick={openPalette} aria-label="Search and go to"
|
||||||
|
className="flex items-center gap-2 rounded-full border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-1 text-xs text-[var(--r-sub)] hover:bg-black/5">
|
||||||
|
<SearchIcon /><span className="hidden sm:inline">Search</span><kbd className="hidden rounded border border-[var(--r-line)] px-1 text-[10px] md:inline">⌘K</kbd>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<AccountMenu />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div id="content" tabIndex={-1} className="focus:outline-none">
|
||||||
|
<div className={`mx-auto flex ${maxWidth} flex-wrap items-center gap-3 px-4 pt-6`}>
|
||||||
|
{onBack && (
|
||||||
|
<button type="button" onClick={onBack} aria-label="Back" className="-ml-2 flex h-9 w-9 items-center justify-center rounded-lg text-[var(--r-sub)] hover:bg-black/5"><ChevronLeft /></button>
|
||||||
|
)}
|
||||||
|
<h1 className="text-2xl font-medium tracking-tight">{title}</h1>
|
||||||
|
{actions && <div className="ml-auto flex flex-wrap items-center gap-2">{actions}</div>}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// People, places and events (Theographic data) tied to the chunk's verses.
|
||||||
|
import { loadTheographic, useAsync } from '../../hooks/useVerseStudy.js';
|
||||||
|
import { PersonCard } from '../StudyPanel.jsx';
|
||||||
|
|
||||||
|
export default function PeopleChunkTab({ book, chapter, chunk }) {
|
||||||
|
const { status, data } = useAsync(() => loadTheographic(book, chapter), [book, chapter]);
|
||||||
|
if (status === 'loading') return <p className="text-sm text-[var(--r-sub)]">Loading…</p>;
|
||||||
|
if (status === 'empty') return <p className="text-sm text-[var(--r-sub)]">No people or places data for this chapter.</p>;
|
||||||
|
const inChunk = (x) => x.verses?.some((n) => n >= chunk.startVerse && n <= chunk.endVerse);
|
||||||
|
const people = data.people.filter(inChunk);
|
||||||
|
const places = [...new Map(data.places.filter(inChunk).map((p) => [p.name, p])).values()];
|
||||||
|
const events = data.events.filter(inChunk);
|
||||||
|
if (!people.length && !places.length && !events.length) return <p className="text-sm text-[var(--r-sub)]">Nothing tagged in these verses.</p>;
|
||||||
|
const head = 'mb-1 text-[11px] uppercase tracking-wide text-[var(--r-sub)]';
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{people.length > 0 && <div><p className={head}>People</p><ul className="space-y-1">{people.map((p) => <PersonCard key={p.id} person={p} />)}</ul></div>}
|
||||||
|
{places.length > 0 && (
|
||||||
|
<div><p className={head}>Places</p>
|
||||||
|
<ul className="space-y-1">{places.map((p) => (
|
||||||
|
<li key={p.id} className="flex items-center justify-between rounded-xl bg-[var(--r-bg)] px-3 py-2 text-sm">
|
||||||
|
<span>{p.name}{p.featureType && <span className="ml-1 text-xs text-[var(--r-sub)]">{p.featureType}</span>}</span>
|
||||||
|
{p.latitude != null && <a href={`https://www.openstreetmap.org/?mlat=${p.latitude}&mlon=${p.longitude}#map=8/${p.latitude}/${p.longitude}`} target="_blank" rel="noopener noreferrer" className="text-xs text-[var(--r-accent)] hover:underline">Map ↗</a>}
|
||||||
|
</li>))}</ul></div>
|
||||||
|
)}
|
||||||
|
{events.length > 0 && <div><p className={head}>Events</p><ul className="space-y-1">{events.map((e) => <li key={e.id} className="rounded-xl bg-[var(--r-bg)] px-3 py-2 text-sm">{e.name}{e.startDate && <span className="ml-1 text-xs text-[var(--r-sub)]">approx. AD {e.startDate}</span>}</li>)}</ul></div>}
|
||||||
|
<p className="text-[10px] text-[var(--r-sub)]">People, places and events: Theographic Bible Metadata, CC BY-SA 4.0.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Right-hand pane of the Study stage: commentary, word study, cross-references and
|
||||||
|
// people & places, all driven by the selected chunk.
|
||||||
|
import { useState } from 'react';
|
||||||
|
import CommentaryChunkTab from './CommentaryChunkTab.jsx';
|
||||||
|
import WordStudyTab from './WordStudyTab.jsx';
|
||||||
|
import CrossRefsTab from './CrossRefsTab.jsx';
|
||||||
|
import PeopleChunkTab from './PeopleChunkTab.jsx';
|
||||||
|
|
||||||
|
export const RESOURCE_TABS = [
|
||||||
|
{ id: 'commentary', label: 'Commentary' },
|
||||||
|
{ id: 'words', label: 'Word study' },
|
||||||
|
{ id: 'refs', label: 'Cross-refs' },
|
||||||
|
{ id: 'people', label: 'People & places' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex, project, updateChunk, notify, resources, wordStudy, crossRefs, openWordId, setOpenWordId }) {
|
||||||
|
const counts = { words: chunk.greekWords.length, refs: (chunk.crossReferences ?? []).length };
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
|
<div role="tablist" aria-label="Study resources" className="flex shrink-0 flex-wrap gap-1.5 border-b border-[var(--r-line)] px-3 py-2.5">
|
||||||
|
{RESOURCE_TABS.map((t) => (
|
||||||
|
<button key={t.id} type="button" role="tab" aria-selected={tab === t.id} onClick={() => setTab(t.id)}
|
||||||
|
className={`rounded-full border px-3 py-1 text-xs ${tab === t.id ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
|
||||||
|
{t.label}{counts[t.id] ? ` · ${counts[t.id]}` : ''}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div role="tabpanel" className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
||||||
|
{tab === 'commentary' && <CommentaryChunkTab resources={resources} chunk={chunk} project={project} chapterIndex={chapterIndex} updateChunk={updateChunk} notify={notify} />}
|
||||||
|
{tab === 'words' && <WordStudyTab wordStudy={wordStudy} chunk={chunk} openWordId={openWordId} setOpenWordId={setOpenWordId} />}
|
||||||
|
{tab === 'refs' && <CrossRefsTab crossRefs={crossRefs} chunk={chunk} />}
|
||||||
|
{tab === 'people' && <PeopleChunkTab book={chapter.bookAbbrev} chapter={chapter.chapter} chunk={chunk} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useResourceTab() {
|
||||||
|
const [tab, setTabState] = useState(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('studyResourceTab');
|
||||||
|
return RESOURCE_TABS.some((t) => t.id === saved) ? saved : 'commentary';
|
||||||
|
} catch { return 'commentary'; }
|
||||||
|
});
|
||||||
|
const setTab = (id) => {
|
||||||
|
setTabState(id);
|
||||||
|
try { localStorage.setItem('studyResourceTab', id); } catch { /* storage unavailable */ }
|
||||||
|
};
|
||||||
|
return [tab, setTab];
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// The chunk's passage, set like the Reader. Click a verse number to open its
|
||||||
|
// interlinear words; click an original word to add it to the chunk's word study.
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { NT_BOOK_NUMBER } from '../../utils/bibleUtils.jsx';
|
||||||
|
|
||||||
|
export default function ScripturePane({ verses, chapter, interlinear, interlinearLoading, addedStrongs, onPickWord }) {
|
||||||
|
const [openVerse, setOpenVerse] = useState(null);
|
||||||
|
const isHebrew = chapter && !NT_BOOK_NUMBER[chapter.bookAbbrev];
|
||||||
|
return (
|
||||||
|
<section aria-label="Scripture" className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] px-5 py-4">
|
||||||
|
<div className="font-serif text-[1.05rem] leading-[1.85]">
|
||||||
|
{verses.map((v) => {
|
||||||
|
const words = v.chapter === chapter?.chapter ? interlinear?.[String(v.number)] : null;
|
||||||
|
const open = openVerse === `${v.chapter}:${v.number}`;
|
||||||
|
return (
|
||||||
|
<div key={`${v.chapter}-${v.number}`} className="mb-1.5">
|
||||||
|
<button type="button" disabled={!words} onClick={() => setOpenVerse(open ? null : `${v.chapter}:${v.number}`)}
|
||||||
|
title={words ? 'Show original words' : undefined}
|
||||||
|
className={`mr-1.5 align-super font-sans text-[0.6em] ${words ? 'text-[var(--r-accent)] hover:underline' : 'cursor-default text-[var(--r-sub)]'} ${open ? 'font-bold' : ''}`}>
|
||||||
|
{v.chapter !== chapter?.chapter ? `${v.chapter}:` : ''}{v.number}
|
||||||
|
</button>
|
||||||
|
<span>{v.text}</span>
|
||||||
|
{open && words && (
|
||||||
|
<div dir={isHebrew ? 'rtl' : 'ltr'} className="mt-2 flex flex-wrap gap-2 rounded-xl bg-[var(--r-bg)] p-2.5 font-sans">
|
||||||
|
{words.map((w, i) => {
|
||||||
|
const added = w.s && addedStrongs.has(w.s.toUpperCase());
|
||||||
|
return (
|
||||||
|
<button key={i} type="button" disabled={!w.s} onClick={() => onPickWord(w)}
|
||||||
|
title={[w.t, w.s, w.p].filter(Boolean).join(' · ') + (w.s ? (added ? ' — already in word study' : ' — click to add') : '')}
|
||||||
|
className={`flex flex-col items-center rounded-lg border px-2 py-1 text-center ${added ? 'border-[var(--r-accent)]' : 'border-[var(--r-line)] hover:bg-black/5'} disabled:cursor-default`}>
|
||||||
|
<span className={`text-base ${isHebrew ? 'font-serif' : ''}`}>{w.o}</span>
|
||||||
|
<span className="text-[11px] leading-tight text-[var(--r-sub)]">{w.g || '—'}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{interlinearLoading && <p className="mt-2 font-sans text-xs text-[var(--r-sub)]">Loading original words…</p>}
|
||||||
|
{!interlinearLoading && interlinear && <p className="mt-2 font-sans text-[11px] text-[var(--r-sub)]">Click a verse number to see its original words; click a word to study it.</p>}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// Shared frame for every stage of a study project (Outline, Study, Script,
|
||||||
|
// Export): themed like the Reader, with a slim bar that carries the project title,
|
||||||
|
// the stage switcher, and save/sync status.
|
||||||
|
import { useApp } from '../../context/AppContext.js';
|
||||||
|
import { themeVars, READER_THEMES } from '../reader/readerThemes.js';
|
||||||
|
import { ChevronLeft } from '../reader/icons.jsx';
|
||||||
|
|
||||||
|
export const STAGES = [
|
||||||
|
{ id: 'setup', label: 'Chunks' },
|
||||||
|
{ id: 'study', label: 'Study' },
|
||||||
|
{ id: 'outline', label: 'Outline' },
|
||||||
|
{ id: 'script', label: 'Script' },
|
||||||
|
{ id: 'export', label: 'Export' },
|
||||||
|
];
|
||||||
|
const THEME_ORDER = ['paper', 'sepia', 'dark'];
|
||||||
|
|
||||||
|
export default function StudioShell({ stage, loading = false, fixedHeight = false, children }) {
|
||||||
|
const {
|
||||||
|
goHome, setCurrentPage, project, allChunks,
|
||||||
|
saveStatus, syncStatus, retrySync, syncConflict, keepMyVersion, useServerVersion,
|
||||||
|
readerTheme, setReaderTheme,
|
||||||
|
} = useApp();
|
||||||
|
const noChunks = allChunks.length === 0;
|
||||||
|
const cycleTheme = () => setReaderTheme(THEME_ORDER[(THEME_ORDER.indexOf(readerTheme) + 1) % THEME_ORDER.length]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`bg-[var(--r-bg)] font-sans text-[var(--r-text)] ${fixedHeight ? 'fixed inset-0 flex flex-col overflow-hidden' : 'min-h-dvh'}`} style={themeVars(readerTheme)}>
|
||||||
|
<div className="safe-top z-30 shrink-0 border-b border-[var(--r-line)] bg-[var(--r-bar)]">
|
||||||
|
<div className="mx-auto flex h-12 max-w-[1600px] items-center gap-3 px-3">
|
||||||
|
<button type="button" onClick={goHome} aria-label="Back to home"
|
||||||
|
className="flex h-9 items-center gap-0.5 rounded-lg pl-1 pr-2 text-[var(--r-sub)] hover:bg-black/5"><ChevronLeft /><span className="hidden text-xs sm:inline">Home</span></button>
|
||||||
|
<div className="min-w-0 max-w-[16rem] truncate text-sm font-medium">{project?.title ?? ''}</div>
|
||||||
|
<nav aria-label="Study stages" className="mx-auto flex gap-0.5">
|
||||||
|
{STAGES.map((s) => {
|
||||||
|
const disabled = s.id !== 'setup' && (!project || noChunks);
|
||||||
|
return (
|
||||||
|
<button key={s.id} type="button" disabled={disabled} aria-current={stage === s.id ? 'step' : undefined}
|
||||||
|
title={disabled ? 'Add a chunk in Chunks first' : undefined}
|
||||||
|
onClick={() => setCurrentPage(s.id)}
|
||||||
|
className={`rounded-full px-3.5 py-1.5 text-xs ${stage === s.id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'} disabled:opacity-40 disabled:hover:bg-transparent`}>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
<div className="flex min-w-[7rem] items-center justify-end gap-2 text-xs text-[var(--r-sub)]" aria-live="polite">
|
||||||
|
{loading ? <span>Loading…</span>
|
||||||
|
: saveStatus ? <span className="text-[var(--r-accent)]">{saveStatus}</span>
|
||||||
|
: syncStatus === 'syncing' ? <span>Syncing…</span>
|
||||||
|
: syncStatus === 'synced' ? <span>Saved</span>
|
||||||
|
: syncStatus === 'error' ? (
|
||||||
|
<span className="text-[var(--r-danger,#b42318)]">Sync failed <button type="button" onClick={retrySync} className="underline">Retry</button></span>
|
||||||
|
) : null}
|
||||||
|
<button type="button" onClick={cycleTheme} className="rounded-lg px-2 py-1 hover:bg-black/5" title="Change theme">{READER_THEMES[readerTheme]?.label}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{syncConflict && project && syncConflict.id === project.id && (
|
||||||
|
<div role="alert" className="flex shrink-0 flex-wrap items-center justify-center gap-x-4 gap-y-2 border-b border-[var(--r-line)] bg-[var(--r-hl)] px-4 py-2.5 text-sm">
|
||||||
|
<span>This study was changed on another device. Your edits here are saved on this device, but not yet synced.</span>
|
||||||
|
<span className="flex gap-2">
|
||||||
|
<button type="button" onClick={keepMyVersion} className="rounded-full bg-[var(--r-text)] px-3 py-1 text-xs font-medium text-[var(--r-bg)] hover:opacity-90">Keep this version</button>
|
||||||
|
<button type="button" onClick={useServerVersion} className="rounded-full border border-[var(--r-text)] px-3 py-1 text-xs font-medium hover:bg-black/5">Use the other version</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Picker for "suggest words from this passage": checkbox list of candidate words.
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
import Sheet from '../reader/Sheet.jsx';
|
||||||
|
import { buildGreekDefinitionHtml } from '../../utils/wordStudy.js';
|
||||||
|
|
||||||
|
export default function SuggestWordsSheet({ wordStudy }) {
|
||||||
|
const { suggestModal, setSuggestModal, suggestSelection, setSuggestSelection, confirmSuggestWords } = wordStudy;
|
||||||
|
if (!suggestModal) return null;
|
||||||
|
const close = () => { setSuggestModal(null); setSuggestSelection(new Set()); };
|
||||||
|
const n = suggestSelection.size;
|
||||||
|
return (
|
||||||
|
<Sheet open onClose={close} title={`Select ${suggestModal.language === 'hebrew' ? 'Hebrew' : 'Greek'} words to add`}>
|
||||||
|
<p className="mb-2 text-xs text-[var(--r-sub)]">{suggestModal.helperText || "Content words are pre-checked. Uncheck any you don't need."}</p>
|
||||||
|
<div className="mb-2 flex items-center gap-3 text-xs">
|
||||||
|
<button type="button" onClick={() => setSuggestSelection(new Set(suggestModal.words.map((w) => w.strongKey)))} className="text-[var(--r-accent)] hover:underline">Select all</button>
|
||||||
|
<button type="button" onClick={() => setSuggestSelection(new Set())} className="text-[var(--r-accent)] hover:underline">Select none</button>
|
||||||
|
<span className="ml-auto text-[var(--r-sub)]">{n} selected</span>
|
||||||
|
</div>
|
||||||
|
<ul className="max-h-[40vh] divide-y divide-[var(--r-line)] overflow-y-auto rounded-xl border border-[var(--r-line)] px-3">
|
||||||
|
{suggestModal.words.map((w) => (
|
||||||
|
<li key={w.strongKey}>
|
||||||
|
<label className="flex cursor-pointer items-center gap-3 py-2.5">
|
||||||
|
<input type="checkbox" checked={suggestSelection.has(w.strongKey)} className="h-4 w-4 shrink-0 accent-[var(--r-accent)]"
|
||||||
|
onChange={(e) => setSuggestSelection((prev) => { const next = new Set(prev); if (e.target.checked) next.add(w.strongKey); else next.delete(w.strongKey); return next; })} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-baseline gap-2">
|
||||||
|
<span className="text-sm font-medium">{w.def || w.strongKey}</span>
|
||||||
|
<span className="text-sm text-[var(--r-sub)]">{w.lexeme}</span>
|
||||||
|
{w.translit && <span className="text-xs italic text-[var(--r-sub)]">{w.translit}</span>}
|
||||||
|
<span className="font-mono text-[10px] text-[var(--r-sub)]">{w.strongKey}</span>
|
||||||
|
</div>
|
||||||
|
{w.entry && (w.entry.strongs_def || w.entry.kjv_def || w.entry.derivation) && (
|
||||||
|
<details className="mt-1">
|
||||||
|
<summary className="cursor-pointer text-xs text-[var(--r-accent)]" onClick={(e) => e.stopPropagation()}>Definition</summary>
|
||||||
|
<div className="mt-1 text-xs leading-5" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(buildGreekDefinitionHtml(w.strongKey, w.entry)) }} />
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<button type="button" onClick={close} className="flex-1 rounded-xl border border-[var(--r-line)] py-2 text-sm hover:bg-black/5">Cancel</button>
|
||||||
|
<button type="button" onClick={confirmSuggestWords} disabled={n === 0}
|
||||||
|
className="flex-1 rounded-xl bg-[var(--r-accent)] py-2 text-sm font-medium text-[var(--r-on)] disabled:opacity-40">Add {n > 0 ? `${n} word${n > 1 ? 's' : ''}` : 'words'}</button>
|
||||||
|
</div>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// Greek/Hebrew word study for the chunk: compact cards (one open for editing at a
|
||||||
|
// time), Strong's lookup, and suggestions from the passage.
|
||||||
|
import { useState } from 'react';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
import { externalLookupLinks, speakOriginalWord } from '../../utils/wordStudy.js';
|
||||||
|
|
||||||
|
const inputCls = 'mt-1 block w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
|
||||||
|
const btn = 'rounded-lg border border-[var(--r-line)] px-2.5 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50';
|
||||||
|
|
||||||
|
function WordCard({ word, open, onToggle, chunkId, wordStudy }) {
|
||||||
|
const { updateChunkWord, lookupWord, removeGreekWord } = wordStudy;
|
||||||
|
const set = (patch) => updateChunkWord(chunkId, word.id, patch);
|
||||||
|
const field = (label, key, placeholder) => (
|
||||||
|
<label className="block text-xs text-[var(--r-sub)]">{label}
|
||||||
|
<input type="text" value={word[key] ?? ''} placeholder={placeholder} onChange={(e) => set({ [key]: e.target.value })} className={inputCls} />
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
const notFound = (word.shortDefinition === 'No definition found.' || word.shortDefinition === 'Lookup failed.') && !word.lexeme && !word.definitionHtml && word.query.trim();
|
||||||
|
return (
|
||||||
|
<div className={`rounded-xl border ${open ? 'border-[var(--r-accent)]' : 'border-[var(--r-line)]'}`}>
|
||||||
|
<button type="button" onClick={onToggle} aria-expanded={open} className="flex w-full items-baseline gap-2 px-3 py-2 text-left">
|
||||||
|
<span className="font-serif text-lg leading-tight">{word.lexeme || <span className="font-sans text-sm text-[var(--r-sub)]">New word</span>}</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-xs text-[var(--r-sub)]">{[word.transliteration, word.englishGloss || word.shortDefinition].filter(Boolean).join(' · ')}</span>
|
||||||
|
{word.strongNumber && <span className="shrink-0 rounded-full bg-[var(--r-bar)] px-2 py-0.5 text-[10px]">{word.strongNumber}</span>}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="space-y-3 border-t border-[var(--r-line)] px-3 py-3">
|
||||||
|
<label className="block text-xs text-[var(--r-sub)]">Strong’s number or English gloss
|
||||||
|
<input type="text" value={word.query} onChange={(e) => set({ query: e.target.value })}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); lookupWord(chunkId, word.id, 'greek'); } }}
|
||||||
|
placeholder="G4102, H7225, 4102, or a word" className={inputCls} />
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button type="button" onClick={() => lookupWord(chunkId, word.id, 'greek')} className={`${btn} bg-[var(--r-text)] text-[var(--r-bg)] hover:opacity-90`}>{word.loading ? 'Looking up...' : 'Look up Greek'}</button>
|
||||||
|
<button type="button" onClick={() => lookupWord(chunkId, word.id, 'hebrew')} className={btn}>{word.loading ? 'Looking up...' : 'Look up Hebrew'}</button>
|
||||||
|
{word.lexeme && <button type="button" onClick={() => speakOriginalWord(word.lexeme, word.strongNumber)} className={btn} title="Hear pronunciation">Speak</button>}
|
||||||
|
<button type="button" onClick={() => removeGreekWord(chunkId, word.id)} className={`${btn} ml-auto text-[var(--r-danger,#b42318)]`}>Delete</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{field(word.strongNumber?.startsWith('H') ? 'Hebrew word' : 'Greek word', 'lexeme')}
|
||||||
|
{field('Transliteration', 'transliteration')}
|
||||||
|
{field('Part of speech', 'partOfSpeech', 'e.g. Noun')}
|
||||||
|
{field('English word', 'englishGloss', 'e.g. grace')}
|
||||||
|
</div>
|
||||||
|
{field('Short definition', 'shortDefinition')}
|
||||||
|
{word.definitionHtml && (
|
||||||
|
<details className="rounded-lg border border-[var(--r-line)] p-2.5">
|
||||||
|
<summary className="cursor-pointer text-xs font-medium">Extended definition</summary>
|
||||||
|
<div className="mt-2 text-[13px] leading-relaxed" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(word.definitionHtml) }} />
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
{notFound ? (
|
||||||
|
<div className="rounded-lg border border-[var(--r-line)] p-2.5">
|
||||||
|
<p className="mb-1.5 text-xs text-[var(--r-sub)]">Not found in BDBT — look it up manually:</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{externalLookupLinks(word.query).map(({ label, url }) => (
|
||||||
|
<a key={label} href={url} target="_blank" rel="noreferrer" className={btn}>{label} ↗</a>
|
||||||
|
))}
|
||||||
|
<button type="button" onClick={() => navigator.clipboard.writeText(word.query.trim())} className={btn}>Copy “{word.query.trim()}”</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WordStudyTab({ wordStudy, chunk, openWordId, setOpenWordId }) {
|
||||||
|
const { suggestingGreekForChunkId, suggestingHebrewForChunkId, suggestGreekWordsForChunk, suggestHebrewWordsForChunk, addGreekWord } = wordStudy;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-3 flex flex-wrap gap-2">
|
||||||
|
<button type="button" onClick={() => suggestGreekWordsForChunk(chunk.id)} disabled={suggestingGreekForChunkId === chunk.id} className={btn}
|
||||||
|
title="Auto-populate Greek words that actually appear in this passage">{suggestingGreekForChunkId === chunk.id ? 'Suggesting…' : 'Suggest Greek'}</button>
|
||||||
|
<button type="button" onClick={() => suggestHebrewWordsForChunk(chunk.id)} disabled={suggestingHebrewForChunkId === chunk.id} className={btn}
|
||||||
|
title="Suggest Hebrew words for Old Testament passages">{suggestingHebrewForChunkId === chunk.id ? 'Suggesting…' : 'Suggest Hebrew'}</button>
|
||||||
|
<button type="button" onClick={() => setOpenWordId(addGreekWord(chunk.id))} className={`${btn} ml-auto bg-[var(--r-text)] text-[var(--r-bg)] hover:opacity-90`}>Add word</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{chunk.greekWords.length === 0 && (
|
||||||
|
<p className="rounded-xl border border-dashed border-[var(--r-line)] p-4 text-sm text-[var(--r-sub)]">
|
||||||
|
No words yet. Open a verse’s original words in the text and click one, or add a word to look it up.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{chunk.greekWords.map((w) => (
|
||||||
|
<WordCard key={w.id} word={w} open={openWordId === w.id} chunkId={chunk.id} wordStudy={wordStudy}
|
||||||
|
onToggle={() => setOpenWordId(openWordId === w.id ? null : w.id)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,23 @@
|
|||||||
// Chapter audio playback. The Home page's whole-book player and the Reader's
|
// Chapter audio playback. The Home page's whole-book player and the Reader's
|
||||||
// chapter player share one <audio> element; audioModeRef tracks which owns it.
|
// chapter player share one <audio> element; audioModeRef tracks which owns it.
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { bookOptions } from '../utils/bibleUtils.jsx';
|
import { bookOptions, CHAPTER_COUNTS } from '../utils/bibleUtils.jsx';
|
||||||
|
|
||||||
export function useAudio(readerBookAbbrev, readerChapter) {
|
export function useAudio(readerBookAbbrev, readerChapter, readerTranslation = 'BSB') {
|
||||||
const [audioBook, setAudioBook] = useState(bookOptions[0].abbrev);
|
const [audioBook, setAudioBook] = useState(bookOptions[0].abbrev);
|
||||||
const [audioNarrator, setAudioNarrator] = useState('souer');
|
const [audioNarrator, setAudioNarratorState] = useState(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('audio-narrator');
|
||||||
|
return ['david', 'hays', 'souer'].includes(saved) ? saved : 'souer';
|
||||||
|
} catch { return 'souer'; }
|
||||||
|
});
|
||||||
|
const setAudioNarrator = (id) => {
|
||||||
|
setAudioNarratorState(id);
|
||||||
|
try { localStorage.setItem('audio-narrator', id); } catch { /* storage unavailable */ }
|
||||||
|
};
|
||||||
const [audioState, setAudioState] = useState({ status: 'idle', chapter: 0, total: 0 });
|
const [audioState, setAudioState] = useState({ status: 'idle', chapter: 0, total: 0 });
|
||||||
const [readerAudioState, setReaderAudioState] = useState({ status: 'idle', chapter: 0 });
|
const [readerAudioState, setReaderAudioState] = useState({ status: 'idle', chapter: 0 });
|
||||||
|
const [audioProgress, setAudioProgress] = useState({ currentTime: 0, duration: 0 });
|
||||||
const audioRef = useRef(null);
|
const audioRef = useRef(null);
|
||||||
const audioModeRef = useRef('book'); // 'book' | 'reader' — which player owns audioRef
|
const audioModeRef = useRef('book'); // 'book' | 'reader' — which player owns audioRef
|
||||||
const audioBookRef = useRef(audioBook);
|
const audioBookRef = useRef(audioBook);
|
||||||
@@ -16,16 +26,37 @@ export function useAudio(readerBookAbbrev, readerChapter) {
|
|||||||
audioBookRef.current = audioBook;
|
audioBookRef.current = audioBook;
|
||||||
audioNarratorRef.current = audioNarrator;
|
audioNarratorRef.current = audioNarrator;
|
||||||
audioStateRef.current = audioState;
|
audioStateRef.current = audioState;
|
||||||
|
// The Reader's player follows the translation on screen: ESV has its own
|
||||||
|
// recording; every other translation falls back to the BSB narrators.
|
||||||
|
const readerTranslationRef = useRef(readerTranslation);
|
||||||
|
readerTranslationRef.current = readerTranslation;
|
||||||
|
const readerAudioSource = () => (readerTranslationRef.current === 'ESV' ? 'ESV' : 'BSB');
|
||||||
|
|
||||||
const playAudioChapter = async (abbrev, chapterNum, narrator) => {
|
const playAudioChapter = async (abbrev, chapterNum, narrator) => {
|
||||||
|
const source = audioModeRef.current === 'reader' ? readerAudioSource() : 'BSB';
|
||||||
|
let url;
|
||||||
|
let total;
|
||||||
|
if (source === 'ESV') {
|
||||||
|
const res = await fetch(`/api/bible/ESV/audio/${abbrev}/${chapterNum}`, { credentials: 'include' });
|
||||||
|
if (!res.ok) throw new Error('Failed to load ESV audio');
|
||||||
|
url = (await res.json()).url;
|
||||||
|
total = CHAPTER_COUNTS[abbrev] ?? chapterNum;
|
||||||
|
} else {
|
||||||
const res = await fetch(`https://bible.helloao.org/api/BSB/${abbrev}/${chapterNum}.json`);
|
const res = await fetch(`https://bible.helloao.org/api/BSB/${abbrev}/${chapterNum}.json`);
|
||||||
if (!res.ok) throw new Error('Failed to load chapter audio');
|
if (!res.ok) throw new Error('Failed to load chapter audio');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const total = data.book?.numberOfChapters ?? chapterNum;
|
total = data.book?.numberOfChapters ?? chapterNum;
|
||||||
const url = data.thisChapterAudioLinks?.[narrator];
|
url = data.thisChapterAudioLinks?.[narrator];
|
||||||
|
}
|
||||||
if (!url) throw new Error('Audio not available for this narrator');
|
if (!url) throw new Error('Audio not available for this narrator');
|
||||||
if (!audioRef.current) {
|
if (!audioRef.current) {
|
||||||
audioRef.current = new Audio();
|
audioRef.current = new Audio();
|
||||||
|
audioRef.current.addEventListener('timeupdate', () => {
|
||||||
|
setAudioProgress({ currentTime: audioRef.current.currentTime, duration: audioRef.current.duration || 0 });
|
||||||
|
});
|
||||||
|
audioRef.current.addEventListener('loadedmetadata', () => {
|
||||||
|
setAudioProgress((prev) => ({ ...prev, duration: audioRef.current.duration || 0 }));
|
||||||
|
});
|
||||||
audioRef.current.addEventListener('ended', () => {
|
audioRef.current.addEventListener('ended', () => {
|
||||||
if (audioModeRef.current === 'reader') {
|
if (audioModeRef.current === 'reader') {
|
||||||
setReaderAudioState({ status: 'idle', chapter: 0 });
|
setReaderAudioState({ status: 'idle', chapter: 0 });
|
||||||
@@ -45,7 +76,7 @@ export function useAudio(readerBookAbbrev, readerChapter) {
|
|||||||
audioEl.src = url;
|
audioEl.src = url;
|
||||||
await audioEl.play();
|
await audioEl.play();
|
||||||
if (audioModeRef.current === 'reader') {
|
if (audioModeRef.current === 'reader') {
|
||||||
setReaderAudioState({ status: 'playing', chapter: chapterNum });
|
setReaderAudioState({ status: 'playing', chapter: chapterNum, source });
|
||||||
} else {
|
} else {
|
||||||
setAudioState({ status: 'playing', chapter: chapterNum, total });
|
setAudioState({ status: 'playing', chapter: chapterNum, total });
|
||||||
}
|
}
|
||||||
@@ -60,6 +91,20 @@ export function useAudio(readerBookAbbrev, readerChapter) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Starts book-mode playback at an arbitrary chapter (e.g. a reading plan's
|
||||||
|
// "today" chapter) instead of always chapter 1. Explicit args avoid relying
|
||||||
|
// on reader state that may not have re-rendered yet if called right after
|
||||||
|
// navigating there in the same event handler.
|
||||||
|
const handlePlayBookAudioFrom = async (abbrev, startChapter) => {
|
||||||
|
audioModeRef.current = 'book';
|
||||||
|
setAudioBook(abbrev);
|
||||||
|
try {
|
||||||
|
await playAudioChapter(abbrev, startChapter, audioNarrator);
|
||||||
|
} catch {
|
||||||
|
setAudioState({ status: 'error', chapter: 0, total: 0 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleStopBookAudio = () => {
|
const handleStopBookAudio = () => {
|
||||||
if (audioRef.current) {
|
if (audioRef.current) {
|
||||||
audioRef.current.pause();
|
audioRef.current.pause();
|
||||||
@@ -67,6 +112,7 @@ export function useAudio(readerBookAbbrev, readerChapter) {
|
|||||||
}
|
}
|
||||||
setAudioState({ status: 'idle', chapter: 0, total: 0 });
|
setAudioState({ status: 'idle', chapter: 0, total: 0 });
|
||||||
setReaderAudioState({ status: 'idle', chapter: 0 });
|
setReaderAudioState({ status: 'idle', chapter: 0 });
|
||||||
|
setAudioProgress({ currentTime: 0, duration: 0 });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleBookAudioPause = () => {
|
const handleToggleBookAudioPause = () => {
|
||||||
@@ -80,6 +126,29 @@ export function useAudio(readerBookAbbrev, readerChapter) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSeekAudio = (deltaSeconds) => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
const el = audioRef.current;
|
||||||
|
const duration = Number.isFinite(el.duration) ? el.duration : Infinity;
|
||||||
|
el.currentTime = Math.min(Math.max(el.currentTime + deltaSeconds, 0), duration);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSeekAudioTo = (seconds) => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
const el = audioRef.current;
|
||||||
|
const duration = Number.isFinite(el.duration) ? el.duration : 0;
|
||||||
|
el.currentTime = Math.min(Math.max(seconds, 0), duration);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSkipBookChapter = () => {
|
||||||
|
const nextChapter = audioStateRef.current.chapter + 1;
|
||||||
|
if (nextChapter > audioStateRef.current.total) return;
|
||||||
|
audioModeRef.current = 'book';
|
||||||
|
playAudioChapter(audioBookRef.current, nextChapter, audioNarratorRef.current).catch(() =>
|
||||||
|
setAudioState({ status: 'error', chapter: 0, total: 0 }),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const handlePlayReaderAudio = async () => {
|
const handlePlayReaderAudio = async () => {
|
||||||
audioModeRef.current = 'reader';
|
audioModeRef.current = 'reader';
|
||||||
try {
|
try {
|
||||||
@@ -104,7 +173,7 @@ export function useAudio(readerBookAbbrev, readerChapter) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (audioModeRef.current !== 'reader') return;
|
if (audioModeRef.current !== 'reader') return;
|
||||||
if (readerAudioState.status !== 'playing' && readerAudioState.status !== 'paused') return;
|
if (readerAudioState.status !== 'playing' && readerAudioState.status !== 'paused') return;
|
||||||
if (readerAudioState.chapter === readerChapter) return;
|
if (readerAudioState.chapter === readerChapter && readerAudioState.source === readerAudioSource()) return;
|
||||||
const wasPaused = readerAudioState.status === 'paused';
|
const wasPaused = readerAudioState.status === 'paused';
|
||||||
playAudioChapter(readerBookAbbrev, readerChapter, audioNarrator)
|
playAudioChapter(readerBookAbbrev, readerChapter, audioNarrator)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -115,7 +184,7 @@ export function useAudio(readerBookAbbrev, readerChapter) {
|
|||||||
})
|
})
|
||||||
.catch(() => setReaderAudioState({ status: 'error', chapter: 0 }));
|
.catch(() => setReaderAudioState({ status: 'error', chapter: 0 }));
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [readerBookAbbrev, readerChapter]);
|
}, [readerBookAbbrev, readerChapter, readerTranslation]);
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
if (audioRef.current) {
|
if (audioRef.current) {
|
||||||
@@ -128,9 +197,14 @@ export function useAudio(readerBookAbbrev, readerChapter) {
|
|||||||
audioBook, setAudioBook,
|
audioBook, setAudioBook,
|
||||||
audioNarrator, setAudioNarrator,
|
audioNarrator, setAudioNarrator,
|
||||||
audioState,
|
audioState,
|
||||||
|
audioProgress,
|
||||||
handlePlayBookAudio,
|
handlePlayBookAudio,
|
||||||
|
handlePlayBookAudioFrom,
|
||||||
handleStopBookAudio,
|
handleStopBookAudio,
|
||||||
handleToggleBookAudioPause,
|
handleToggleBookAudioPause,
|
||||||
|
handleSkipBookChapter,
|
||||||
|
handleSeekAudio,
|
||||||
|
handleSeekAudioTo,
|
||||||
readerAudioState,
|
readerAudioState,
|
||||||
handlePlayReaderAudio,
|
handlePlayReaderAudio,
|
||||||
handleToggleReaderAudioPause,
|
handleToggleReaderAudioPause,
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
// The list of Bible translations offered by the HelloAO API, fetched once per
|
|
||||||
// visit and shared by every page that needs it (Setup, Import).
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
let cached = null;
|
|
||||||
let pending = null;
|
|
||||||
|
|
||||||
export function useAvailableTranslations() {
|
|
||||||
const [translations, setTranslations] = useState(cached ?? ['BSB']);
|
|
||||||
useEffect(() => {
|
|
||||||
if (cached) return;
|
|
||||||
pending ??= fetch('https://bible.helloao.org/api/available_translations.json')
|
|
||||||
.then((res) => res.json())
|
|
||||||
.then((data) => {
|
|
||||||
if (Array.isArray(data) && data.length > 0) cached = data;
|
|
||||||
return cached;
|
|
||||||
})
|
|
||||||
.catch(() => null)
|
|
||||||
.finally(() => { pending = null; });
|
|
||||||
let active = true;
|
|
||||||
pending.then((list) => { if (active && list) setTranslations(list); });
|
|
||||||
return () => { active = false; };
|
|
||||||
}, []);
|
|
||||||
return translations;
|
|
||||||
}
|
|
||||||
@@ -2,14 +2,77 @@
|
|||||||
// Audio playback stays in App because the study page's book player shares
|
// Audio playback stays in App because the study page's book player shares
|
||||||
// the same <audio> element.
|
// the same <audio> element.
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { bookOptions, parseBibleChapter } from '../utils/bibleUtils.jsx';
|
import { bookOptions, CHAPTER_COUNTS, parseBibleChapter } from '../utils/bibleUtils.jsx';
|
||||||
|
import { useReaderMarksSync } from './useReaderMarksSync.js';
|
||||||
|
import { usePrefsSync } from './usePrefsSync.js';
|
||||||
|
import { COMMENTARIES } from './useVerseStudy.js';
|
||||||
|
import { READER_TRANSLATIONS, fetchChapter } from '../utils/bibleText.js';
|
||||||
|
import { mergeMarks, normalizeMarks } from '../utils/readerMarks.js';
|
||||||
import { BIBLE_IDB_TTL, getBibleIndexFromIDB, saveBibleIndexToIDB } from '../utils/bibleIndexCache.js';
|
import { BIBLE_IDB_TTL, getBibleIndexFromIDB, saveBibleIndexToIDB } from '../utils/bibleIndexCache.js';
|
||||||
|
|
||||||
|
export { READER_TRANSLATIONS };
|
||||||
|
|
||||||
|
// A per-device choice kept in localStorage: useStoredChoice('reader-font', 'serif', ['serif', 'sans']).
|
||||||
|
function useStoredChoice(key, fallback, allowed) {
|
||||||
|
const [value, setValue] = useState(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(key);
|
||||||
|
return saved !== null && allowed.includes(saved) ? saved : fallback;
|
||||||
|
} catch { return fallback; }
|
||||||
|
});
|
||||||
|
const set = (next) => {
|
||||||
|
setValue(next);
|
||||||
|
try { localStorage.setItem(key, next); } catch {}
|
||||||
|
};
|
||||||
|
return [value, set];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const READER_FONTS = ['serif', 'sans', 'easy'];
|
||||||
|
export const READER_SPACINGS = ['snug', 'normal', 'airy'];
|
||||||
|
|
||||||
const BOOKMARK_COLORS = ['#fef08a', '#bbf7d0', '#bfdbfe', '#fecaca', '#e9d5ff'];
|
const BOOKMARK_COLORS = ['#fef08a', '#bbf7d0', '#bfdbfe', '#fecaca', '#e9d5ff'];
|
||||||
|
|
||||||
export function useBibleReader(currentPage) {
|
// `translationOverride` lets a reading plan pin the Bible it is read in while it is
|
||||||
const [readerBookAbbrev, setReaderBookAbbrev] = useState(bookOptions[0].abbrev);
|
// open; otherwise the reader's own (remembered) translation is used.
|
||||||
const [readerChapter, setReaderChapter] = useState(1);
|
export function useBibleReader(currentPage, translationOverride = null) {
|
||||||
|
// Where you left off (saved on this device, and synced to the account)
|
||||||
|
const [savedPosition] = useState(() => {
|
||||||
|
try {
|
||||||
|
const p = JSON.parse(localStorage.getItem('reader-position') || 'null');
|
||||||
|
return p && CHAPTER_COUNTS[p.book] && Number.isInteger(p.chapter) && p.chapter >= 1 && p.chapter <= CHAPTER_COUNTS[p.book] ? p : null;
|
||||||
|
} catch { return null; }
|
||||||
|
});
|
||||||
|
const [readerBookAbbrev, setReaderBookAbbrev] = useState(savedPosition?.book ?? bookOptions[0].abbrev);
|
||||||
|
const [readerChapter, setReaderChapter] = useState(savedPosition?.chapter ?? 1);
|
||||||
|
const [readerCommentary, setReaderCommentaryState] = useState(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('reader-commentary');
|
||||||
|
return COMMENTARIES.some((c) => c.id === saved) ? saved : COMMENTARIES[0].id;
|
||||||
|
} catch { return COMMENTARIES[0].id; }
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
try { localStorage.setItem('reader-position', JSON.stringify({ book: readerBookAbbrev, chapter: readerChapter })); } catch {}
|
||||||
|
}, [readerBookAbbrev, readerChapter]);
|
||||||
|
const pageRef = useRef(currentPage);
|
||||||
|
pageRef.current = currentPage;
|
||||||
|
const [baseTranslation, setReaderTranslationState] = useState(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('reader-translation');
|
||||||
|
return READER_TRANSLATIONS.some((t) => t.id === saved) ? saved : 'BSB';
|
||||||
|
} catch { return 'BSB'; }
|
||||||
|
});
|
||||||
|
const readerTranslation = translationOverride ?? baseTranslation;
|
||||||
|
const [readerCopyright, setReaderCopyright] = useState('');
|
||||||
|
const [readerTheme, setReaderThemeState] = useState(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('reader-theme');
|
||||||
|
return ['paper', 'sepia', 'dark'].includes(saved) ? saved : 'paper';
|
||||||
|
} catch { return 'paper'; }
|
||||||
|
});
|
||||||
|
// 'flow' = verses run together like a printed page; 'verses' = one verse per line
|
||||||
|
const [readerLayout, setReaderLayoutState] = useState(() => {
|
||||||
|
try { return localStorage.getItem('reader-layout') === 'verses' ? 'verses' : 'flow'; } catch { return 'flow'; }
|
||||||
|
});
|
||||||
const [readerVerses, setReaderVerses] = useState([]);
|
const [readerVerses, setReaderVerses] = useState([]);
|
||||||
const [readerTotalChapters, setReaderTotalChapters] = useState(1);
|
const [readerTotalChapters, setReaderTotalChapters] = useState(1);
|
||||||
const [readerLoading, setReaderLoading] = useState(false);
|
const [readerLoading, setReaderLoading] = useState(false);
|
||||||
@@ -18,7 +81,52 @@ export function useBibleReader(currentPage) {
|
|||||||
const [readerInterlinearLoading, setReaderInterlinearLoading] = useState(false);
|
const [readerInterlinearLoading, setReaderInterlinearLoading] = useState(false);
|
||||||
const [readerSelectedVerse, setReaderSelectedVerse] = useState(null); // verse number with open interlinear panel
|
const [readerSelectedVerse, setReaderSelectedVerse] = useState(null); // verse number with open interlinear panel
|
||||||
const _readerInterlinearCacheRef = useRef({});
|
const _readerInterlinearCacheRef = useRef({});
|
||||||
const [readerFontSize, setReaderFontSize] = useState(1); // em multiplier: 0.875 | 1 | 1.125 | 1.25
|
// Text size and reading width are per-device (a phone and a tablet want different ones)
|
||||||
|
const [readerFontSize, setReaderFontSizeState] = useState(() => {
|
||||||
|
try {
|
||||||
|
const n = Number(localStorage.getItem('reader-font-size'));
|
||||||
|
return [0.875, 1, 1.125, 1.25, 1.5].includes(n) ? n : 1;
|
||||||
|
} catch { return 1; }
|
||||||
|
}); // em multiplier: 0.875 | 1 | 1.125 | 1.25 | 1.5
|
||||||
|
const setReaderFontSize = (n) => {
|
||||||
|
setReaderFontSizeState(n);
|
||||||
|
try { localStorage.setItem('reader-font-size', String(n)); } catch {}
|
||||||
|
};
|
||||||
|
const [readerWide, setReaderWideState] = useState(() => {
|
||||||
|
try { return localStorage.getItem('reader-wide') === '1'; } catch { return false; }
|
||||||
|
});
|
||||||
|
const setReaderWide = (on) => {
|
||||||
|
setReaderWideState(!!on);
|
||||||
|
try { localStorage.setItem('reader-wide', on ? '1' : '0'); } catch {}
|
||||||
|
};
|
||||||
|
// Typography and a second Bible shown beside the first — all per-device, like text size.
|
||||||
|
const [readerFont, setReaderFont] = useStoredChoice('reader-font', 'serif', READER_FONTS);
|
||||||
|
const [readerSpacing, setReaderSpacing] = useStoredChoice('reader-spacing', 'normal', READER_SPACINGS);
|
||||||
|
const [readerVerseNumbers, setReaderVerseNumbersRaw] = useStoredChoice('reader-verse-numbers', 'on', ['on', 'off']);
|
||||||
|
const [readerShowDiff, setReaderShowDiffRaw] = useStoredChoice('reader-show-diff', 'on', ['on', 'off']);
|
||||||
|
const [readerParallel, setReaderParallel] = useStoredChoice('reader-parallel', '', ['', ...READER_TRANSLATIONS.map((t) => t.id)]);
|
||||||
|
// The page the app opens to, and where every Home button goes: 'studies' | 'reader' | 'plans'
|
||||||
|
const [startPagePref, setStartPagePrefState] = useState(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('start-page');
|
||||||
|
return ['studies', 'reader', 'plans'].includes(saved) ? saved : 'studies';
|
||||||
|
} catch { return 'studies'; }
|
||||||
|
});
|
||||||
|
const setStartPagePref = (id) => {
|
||||||
|
setStartPagePrefState(id);
|
||||||
|
try { localStorage.setItem('start-page', id); } catch {}
|
||||||
|
};
|
||||||
|
// Which Bible a newly started reading plan begins in: 'same' = whatever the default Bible is
|
||||||
|
const [planTranslationPref, setPlanTranslationPrefState] = useState(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('plan-translation');
|
||||||
|
return saved === 'same' || READER_TRANSLATIONS.some((t) => t.id === saved) ? saved : 'same';
|
||||||
|
} catch { return 'same'; }
|
||||||
|
});
|
||||||
|
const setPlanTranslationPref = (id) => {
|
||||||
|
setPlanTranslationPrefState(id);
|
||||||
|
try { localStorage.setItem('plan-translation', id); } catch {}
|
||||||
|
};
|
||||||
const [readerBookmarks, setReaderBookmarks] = useState(() => {
|
const [readerBookmarks, setReaderBookmarks] = useState(() => {
|
||||||
try { return JSON.parse(localStorage.getItem('reader-bookmarks') || '{}'); } catch { return {}; }
|
try { return JSON.parse(localStorage.getItem('reader-bookmarks') || '{}'); } catch { return {}; }
|
||||||
});
|
});
|
||||||
@@ -31,7 +139,6 @@ export function useBibleReader(currentPage) {
|
|||||||
const [readerSearch, setReaderSearch] = useState('');
|
const [readerSearch, setReaderSearch] = useState('');
|
||||||
const [readerSearchActive, setReaderSearchActive] = useState(false);
|
const [readerSearchActive, setReaderSearchActive] = useState(false);
|
||||||
const [readerSearchScope, setReaderSearchScope] = useState('chapter'); // 'chapter' | 'bible'
|
const [readerSearchScope, setReaderSearchScope] = useState('chapter'); // 'chapter' | 'bible'
|
||||||
const [bibleIndexStatus, setBibleIndexStatus] = useState('idle'); // 'idle' | 'loading' | 'ready' | 'error'
|
|
||||||
const _bibleIndexCacheRef = useRef({}); // translation -> flat verse array
|
const _bibleIndexCacheRef = useRef({}); // translation -> flat verse array
|
||||||
const [readerInkByPage, setReaderInkByPage] = useState(() => {
|
const [readerInkByPage, setReaderInkByPage] = useState(() => {
|
||||||
try { return JSON.parse(localStorage.getItem('readerInkByPage') ?? '{}'); }
|
try { return JSON.parse(localStorage.getItem('readerInkByPage') ?? '{}'); }
|
||||||
@@ -43,30 +150,51 @@ export function useBibleReader(currentPage) {
|
|||||||
return Array.isArray(val) ? val : [];
|
return Array.isArray(val) ? val : [];
|
||||||
} catch { return []; }
|
} catch { return []; }
|
||||||
});
|
});
|
||||||
|
// What each highlight colour means to this reader ({ '#fef08a': 'Promises' }); follows the account.
|
||||||
|
const [readerColorLabels, setReaderColorLabelsState] = useState(() => {
|
||||||
|
try {
|
||||||
|
const val = JSON.parse(localStorage.getItem('reader-color-labels') || '{}');
|
||||||
|
return val && typeof val === 'object' && !Array.isArray(val) ? val : {};
|
||||||
|
} catch { return {}; }
|
||||||
|
});
|
||||||
|
const setReaderColorLabel = (color, label) => {
|
||||||
|
setReaderColorLabelsState((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
if (label.trim()) next[color] = label.slice(0, 30); else delete next[color];
|
||||||
|
try { localStorage.setItem('reader-color-labels', JSON.stringify(next)); } catch {}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
// Debounce timer ref so rapid stroke updates don't flood the server
|
// Debounce timer ref so rapid stroke updates don't flood the server
|
||||||
const inkSaveTimerRef = useRef({});
|
const inkSaveTimerRef = useRef({});
|
||||||
|
|
||||||
// The HelloAO API has no search endpoint, so whole-Bible search fetches the
|
// The HelloAO API has no search endpoint, so whole-Bible search for the free
|
||||||
// entire translation once (~7MB) and searches an in-memory flat verse index
|
// translations (BSB, NET) fetches the entire translation once (~7MB) and searches
|
||||||
// client-side. Cached per translation for the rest of the session (not persisted
|
// an in-memory flat verse index client-side. Cached per translation for the rest of
|
||||||
// across reloads, to avoid eating into localStorage/IndexedDB quota for a cache
|
// the visit, and in IndexedDB for a while. Licensed translations use the server
|
||||||
// that's cheap enough to rebuild once per visit).
|
// search proxy instead (see useBibleSearch).
|
||||||
const loadBibleIndex = async () => {
|
const [bibleIndexState, setBibleIndexState] = useState({ translation: null, status: 'idle' });
|
||||||
if (_bibleIndexCacheRef.current.BSB) {
|
const bibleIndexStatusFor = (translation) => {
|
||||||
setBibleIndexStatus('ready');
|
if (_bibleIndexCacheRef.current[translation]) return 'ready';
|
||||||
return;
|
return bibleIndexState.translation === translation ? bibleIndexState.status : 'idle';
|
||||||
}
|
};
|
||||||
setBibleIndexStatus('loading');
|
|
||||||
|
const loadBibleIndex = async (translation = 'BSB') => {
|
||||||
|
const set = (status) => setBibleIndexState({ translation, status });
|
||||||
|
if (_bibleIndexCacheRef.current[translation]) { set('ready'); return; }
|
||||||
|
const source = READER_TRANSLATIONS.find((t) => t.id === translation);
|
||||||
|
if (!source || source.licensed) { set('error'); return; }
|
||||||
|
set('loading');
|
||||||
try {
|
try {
|
||||||
// Check IndexedDB cache before hitting the network (~7 MB fetch)
|
// Check IndexedDB cache before hitting the network (~7 MB fetch)
|
||||||
const cached = await getBibleIndexFromIDB('BSB');
|
const cached = await getBibleIndexFromIDB(translation);
|
||||||
if (cached?.flat && Date.now() - cached.cachedAt < BIBLE_IDB_TTL) {
|
if (cached?.flat && Date.now() - cached.cachedAt < BIBLE_IDB_TTL) {
|
||||||
_bibleIndexCacheRef.current.BSB = cached.flat;
|
_bibleIndexCacheRef.current[translation] = cached.flat;
|
||||||
setBibleIndexStatus('ready');
|
set('ready');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch('https://bible.helloao.org/api/BSB/complete.json');
|
const res = await fetch(`https://bible.helloao.org/api/${source.helloao ?? source.id}/complete.json`);
|
||||||
if (!res.ok) throw new Error('Unable to load the full Bible for search.');
|
if (!res.ok) throw new Error('Unable to load the full Bible for search.');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const flat = [];
|
const flat = [];
|
||||||
@@ -80,40 +208,63 @@ export function useBibleReader(currentPage) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_bibleIndexCacheRef.current.BSB = flat;
|
_bibleIndexCacheRef.current[translation] = flat;
|
||||||
setBibleIndexStatus('ready');
|
set('ready');
|
||||||
// Persist for future visits — non-blocking
|
// Persist for future visits — non-blocking
|
||||||
saveBibleIndexToIDB('BSB', flat);
|
saveBibleIndexToIDB(translation, flat);
|
||||||
} catch {
|
} catch {
|
||||||
setBibleIndexStatus('error');
|
set('error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadReaderChapter = async (bookAbbrev, chapterNumber) => {
|
const setReaderTheme = (id) => {
|
||||||
|
setReaderThemeState(id);
|
||||||
|
try { localStorage.setItem('reader-theme', id); } catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setReaderLayout = (id) => {
|
||||||
|
setReaderLayoutState(id);
|
||||||
|
try { localStorage.setItem('reader-layout', id); } catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setReaderCommentary = (id) => {
|
||||||
|
setReaderCommentaryState(id);
|
||||||
|
try { localStorage.setItem('reader-commentary', id); } catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setReaderTranslation = (id) => {
|
||||||
|
setReaderTranslationState(id);
|
||||||
|
try { localStorage.setItem('reader-translation', id); } catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// BSB comes straight from HelloAO; licensed translations (NLT/CSB/NASB) go
|
||||||
|
// through the server's api.bible proxy so the API key never reaches the browser.
|
||||||
|
// The most recent request wins: flipping chapters quickly (or changing translation) must never let a
|
||||||
|
// slow earlier response replace the page you are now on.
|
||||||
|
const chapterRequestRef = useRef(0);
|
||||||
|
const loadReaderChapter = async (bookAbbrev, chapterNumber, translation = readerTranslation) => {
|
||||||
|
const requestId = ++chapterRequestRef.current;
|
||||||
setReaderLoading(true);
|
setReaderLoading(true);
|
||||||
setReaderError('');
|
setReaderError('');
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`https://bible.helloao.org/api/BSB/${bookAbbrev}/${chapterNumber}.json`);
|
const { verses, copyright, totalChapters } = await fetchChapter(translation, bookAbbrev, chapterNumber);
|
||||||
if (!res.ok) throw new Error('Unable to load chapter.');
|
if (requestId !== chapterRequestRef.current) return;
|
||||||
const data = await res.json();
|
|
||||||
const verses = parseBibleChapter(data);
|
|
||||||
if (!Array.isArray(verses) || verses.length === 0) {
|
|
||||||
throw new Error('Invalid Bible data returned.');
|
|
||||||
}
|
|
||||||
setReaderVerses(verses);
|
setReaderVerses(verses);
|
||||||
setReaderTotalChapters(data.book?.numberOfChapters ?? chapterNumber);
|
setReaderCopyright(copyright);
|
||||||
|
setReaderTotalChapters(totalChapters);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (requestId !== chapterRequestRef.current) return;
|
||||||
setReaderError(err.message || 'Failed to load chapter.');
|
setReaderError(err.message || 'Failed to load chapter.');
|
||||||
setReaderVerses([]);
|
setReaderVerses([]);
|
||||||
} finally {
|
} finally {
|
||||||
setReaderLoading(false);
|
if (requestId === chapterRequestRef.current) setReaderLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentPage !== 'reader') return;
|
if (currentPage !== 'reader') return;
|
||||||
loadReaderChapter(readerBookAbbrev, readerChapter);
|
loadReaderChapter(readerBookAbbrev, readerChapter, readerTranslation);
|
||||||
}, [currentPage, readerBookAbbrev, readerChapter]);
|
}, [currentPage, readerBookAbbrev, readerChapter, readerTranslation]);
|
||||||
|
|
||||||
// After navigating to a verse (from the bookmarks panel or a whole-Bible search
|
// After navigating to a verse (from the bookmarks panel or a whole-Bible search
|
||||||
// result), scroll to it and briefly flash its background so it's easy to spot.
|
// result), scroll to it and briefly flash its background so it's easy to spot.
|
||||||
@@ -121,7 +272,7 @@ export function useBibleReader(currentPage) {
|
|||||||
if (readerJumpVerse == null || readerLoading) return;
|
if (readerJumpVerse == null || readerLoading) return;
|
||||||
const el = document.getElementById(`reader-verse-${readerJumpVerse}`);
|
const el = document.getElementById(`reader-verse-${readerJumpVerse}`);
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
el.scrollIntoView({ behavior: window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth', block: 'center' });
|
||||||
const prevTransition = el.style.transition;
|
const prevTransition = el.style.transition;
|
||||||
const prevBackground = el.style.backgroundColor;
|
const prevBackground = el.style.backgroundColor;
|
||||||
el.style.transition = 'background-color 0.4s';
|
el.style.transition = 'background-color 0.4s';
|
||||||
@@ -212,6 +363,16 @@ export function useBibleReader(currentPage) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Puts a removed bookmark back with its colour (used by "Undo").
|
||||||
|
const restoreReaderBookmark = (verseKey, color) => {
|
||||||
|
setReaderBookmarks((prev) => {
|
||||||
|
if (prev[verseKey]) return prev;
|
||||||
|
const next = { ...prev, [verseKey]: color };
|
||||||
|
try { localStorage.setItem('reader-bookmarks', JSON.stringify(next)); } catch {}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const updateReaderPageInk = useCallback((bookAbbrev, chapter, strokes) => {
|
const updateReaderPageInk = useCallback((bookAbbrev, chapter, strokes) => {
|
||||||
const key = `${bookAbbrev}_${chapter}`;
|
const key = `${bookAbbrev}_${chapter}`;
|
||||||
setReaderInkByPage((prev) => {
|
setReaderInkByPage((prev) => {
|
||||||
@@ -250,6 +411,16 @@ export function useBibleReader(currentPage) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Puts a removed highlight back exactly as it was (used by "Undo").
|
||||||
|
const restoreTextHighlight = (highlight) => {
|
||||||
|
setReaderTextHighlights((prev) => {
|
||||||
|
if (prev.some((h) => h.id === highlight.id)) return prev;
|
||||||
|
const next = [...prev, highlight];
|
||||||
|
try { localStorage.setItem('readerTextHighlights', JSON.stringify(next)); } catch {}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const removeTextHighlight = (id) => {
|
const removeTextHighlight = (id) => {
|
||||||
setReaderTextHighlights(prev => {
|
setReaderTextHighlights(prev => {
|
||||||
const next = prev.filter(h => h.id !== id);
|
const next = prev.filter(h => h.id !== id);
|
||||||
@@ -282,6 +453,30 @@ export function useBibleReader(currentPage) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Bookmarks, highlights, colour names and ink from a backup file are added to what is here (nothing is
|
||||||
|
// removed, and where both have something the copy already here wins).
|
||||||
|
const importReaderMarks = ({ bookmarks, highlights, colorLabels, ink }) => {
|
||||||
|
const merged = mergeMarks(normalizeMarks({ bookmarks, highlights }), normalizeMarks({ bookmarks: readerBookmarks, highlights: readerTextHighlights }));
|
||||||
|
setReaderBookmarks(merged.bookmarks);
|
||||||
|
setReaderTextHighlights(merged.highlights);
|
||||||
|
try {
|
||||||
|
localStorage.setItem('reader-bookmarks', JSON.stringify(merged.bookmarks));
|
||||||
|
localStorage.setItem('readerTextHighlights', JSON.stringify(merged.highlights));
|
||||||
|
} catch {}
|
||||||
|
if (colorLabels && Object.keys(colorLabels).length > 0) {
|
||||||
|
setReaderColorLabelsState((prev) => {
|
||||||
|
const next = { ...colorLabels, ...prev };
|
||||||
|
try { localStorage.setItem('reader-color-labels', JSON.stringify(next)); } catch {}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Ink goes through the normal save so it also reaches the account
|
||||||
|
for (const [key, strokes] of Object.entries(ink ?? {})) {
|
||||||
|
const [book, chapter] = key.split('_');
|
||||||
|
if (!readerInkByPage[key] && book && chapter && Array.isArray(strokes)) updateReaderPageInk(book, chapter, strokes);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Merges reader ink loaded from the server at sign-in; server wins over local.
|
// Merges reader ink loaded from the server at sign-in; server wins over local.
|
||||||
const mergeServerInk = useCallback((serverInk) => {
|
const mergeServerInk = useCallback((serverInk) => {
|
||||||
setReaderInkByPage((local) => {
|
setReaderInkByPage((local) => {
|
||||||
@@ -291,9 +486,41 @@ export function useBibleReader(currentPage) {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Bookmarks and highlights follow the signed-in user across devices.
|
||||||
|
const marksSync = useReaderMarksSync({
|
||||||
|
bookmarks: readerBookmarks, highlights: readerTextHighlights,
|
||||||
|
setBookmarks: setReaderBookmarks, setHighlights: setReaderTextHighlights,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Theme, default translation, layout, commentary and reading position follow the account too.
|
||||||
|
const prefsSync = usePrefsSync({
|
||||||
|
values: {
|
||||||
|
theme: readerTheme, translation: baseTranslation, layout: readerLayout, commentarySource: readerCommentary,
|
||||||
|
planTranslation: planTranslationPref, startPage: startPagePref,
|
||||||
|
position: { book: readerBookAbbrev, chapter: readerChapter },
|
||||||
|
colorLabels: readerColorLabels,
|
||||||
|
},
|
||||||
|
apply: (key, v) => {
|
||||||
|
if (key === 'theme') { setReaderThemeState(v); try { localStorage.setItem('reader-theme', v); } catch {} }
|
||||||
|
else if (key === 'translation') { setReaderTranslationState(v); try { localStorage.setItem('reader-translation', v); } catch {} }
|
||||||
|
else if (key === 'layout') { setReaderLayoutState(v); try { localStorage.setItem('reader-layout', v); } catch {} }
|
||||||
|
else if (key === 'startPage') { setStartPagePrefState(v); try { localStorage.setItem('start-page', v); } catch {} }
|
||||||
|
else if (key === 'planTranslation') { setPlanTranslationPrefState(v); try { localStorage.setItem('plan-translation', v); } catch {} }
|
||||||
|
else if (key === 'commentarySource') { setReaderCommentaryState(v); try { localStorage.setItem('reader-commentary', v); } catch {} }
|
||||||
|
else if (key === 'colorLabels') { setReaderColorLabelsState(v); try { localStorage.setItem('reader-color-labels', JSON.stringify(v)); } catch {} }
|
||||||
|
else if (key === 'position') { setReaderBookAbbrev(v.book); setReaderChapter(v.chapter); setReaderSelectedVerse(null); }
|
||||||
|
},
|
||||||
|
canApplyPosition: () => pageRef.current !== 'reader',
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
readerBookAbbrev, setReaderBookAbbrev,
|
readerBookAbbrev, setReaderBookAbbrev,
|
||||||
|
readerCommentary, setReaderCommentary,
|
||||||
readerChapter, setReaderChapter,
|
readerChapter, setReaderChapter,
|
||||||
|
readerTranslation, setReaderTranslation,
|
||||||
|
readerCopyright,
|
||||||
|
readerTheme, setReaderTheme,
|
||||||
|
readerLayout, setReaderLayout,
|
||||||
readerVerses,
|
readerVerses,
|
||||||
readerTotalChapters,
|
readerTotalChapters,
|
||||||
readerLoading,
|
readerLoading,
|
||||||
@@ -301,6 +528,14 @@ export function useBibleReader(currentPage) {
|
|||||||
readerInterlinear,
|
readerInterlinear,
|
||||||
readerSelectedVerse, setReaderSelectedVerse,
|
readerSelectedVerse, setReaderSelectedVerse,
|
||||||
readerFontSize, setReaderFontSize,
|
readerFontSize, setReaderFontSize,
|
||||||
|
readerWide, setReaderWide,
|
||||||
|
readerFont, setReaderFont,
|
||||||
|
readerSpacing, setReaderSpacing,
|
||||||
|
readerVerseNumbers: readerVerseNumbers === 'on', setReaderVerseNumbers: (on) => setReaderVerseNumbersRaw(on ? 'on' : 'off'),
|
||||||
|
readerParallel, setReaderParallel,
|
||||||
|
readerShowDiff: readerShowDiff === 'on', setReaderShowDiff: (on) => setReaderShowDiffRaw(on ? 'on' : 'off'),
|
||||||
|
planTranslationPref, setPlanTranslationPref,
|
||||||
|
startPagePref, setStartPagePref,
|
||||||
readerBookmarks, setReaderBookmarks,
|
readerBookmarks, setReaderBookmarks,
|
||||||
readerBookmarksPanelOpen, setReaderBookmarksPanelOpen,
|
readerBookmarksPanelOpen, setReaderBookmarksPanelOpen,
|
||||||
readerCrossRefs, setReaderCrossRefs,
|
readerCrossRefs, setReaderCrossRefs,
|
||||||
@@ -312,10 +547,15 @@ export function useBibleReader(currentPage) {
|
|||||||
readerInkByPage,
|
readerInkByPage,
|
||||||
updateReaderPageInk,
|
updateReaderPageInk,
|
||||||
mergeServerInk,
|
mergeServerInk,
|
||||||
|
importReaderMarks,
|
||||||
readerTextHighlights,
|
readerTextHighlights,
|
||||||
|
...marksSync,
|
||||||
|
...prefsSync,
|
||||||
addTextHighlight,
|
addTextHighlight,
|
||||||
removeTextHighlight,
|
removeTextHighlight,
|
||||||
bibleIndexStatus,
|
restoreTextHighlight,
|
||||||
|
readerColorLabels, setReaderColorLabel,
|
||||||
|
bibleIndexStatusFor,
|
||||||
loadBibleIndex,
|
loadBibleIndex,
|
||||||
_bibleIndexCacheRef,
|
_bibleIndexCacheRef,
|
||||||
loadReaderChapter,
|
loadReaderChapter,
|
||||||
@@ -324,6 +564,7 @@ export function useBibleReader(currentPage) {
|
|||||||
readerGoToNextChapter,
|
readerGoToNextChapter,
|
||||||
jumpToReaderVerse,
|
jumpToReaderVerse,
|
||||||
toggleReaderBookmark,
|
toggleReaderBookmark,
|
||||||
|
restoreReaderBookmark,
|
||||||
cycleBookmarkColor,
|
cycleBookmarkColor,
|
||||||
loadReaderCrossRefs,
|
loadReaderCrossRefs,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Whole-Bible search for translations that can't be downloaded and indexed in the
|
||||||
|
// browser (ESV, NLT, CSB, NASB): queries go through the server's search proxy.
|
||||||
|
// Debounced, cancels stale requests, and pages in 25 verses at a time.
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 450;
|
||||||
|
const EMPTY = { results: [], total: 0, page: 0, hasMore: false, loading: false, error: '' };
|
||||||
|
|
||||||
|
export function useBibleSearch(translation, query, enabled) {
|
||||||
|
const [state, setState] = useState(EMPTY);
|
||||||
|
const controllerRef = useRef(null);
|
||||||
|
const q = query.trim();
|
||||||
|
|
||||||
|
const run = useCallback(async (page) => {
|
||||||
|
controllerRef.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
controllerRef.current = controller;
|
||||||
|
setState((s) => ({ ...s, loading: true, error: '' }));
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/bible/${translation}/search?q=${encodeURIComponent(q)}&page=${page}`, {
|
||||||
|
credentials: 'include', signal: controller.signal,
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Search failed.');
|
||||||
|
setState((s) => ({
|
||||||
|
results: page === 1 ? data.results : [...s.results, ...data.results],
|
||||||
|
total: data.total, page: data.page, hasMore: data.hasMore, loading: false, error: '',
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'AbortError') return;
|
||||||
|
setState((s) => ({ ...s, loading: false, error: err.message || 'Search failed.' }));
|
||||||
|
}
|
||||||
|
}, [translation, q]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || q.length < 2) {
|
||||||
|
controllerRef.current?.abort();
|
||||||
|
setState(EMPTY);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const timer = window.setTimeout(() => run(1), DEBOUNCE_MS);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [enabled, q, run]);
|
||||||
|
|
||||||
|
useEffect(() => () => controllerRef.current?.abort(), []);
|
||||||
|
|
||||||
|
const loadMore = () => { if (!state.loading && state.hasMore) run(state.page + 1); };
|
||||||
|
return { ...state, loadMore, tooShort: q.length < 2 };
|
||||||
|
}
|
||||||
@@ -2,8 +2,80 @@
|
|||||||
// active chapter tab, and every way of creating chunks (click, shift-click,
|
// active chapter tab, and every way of creating chunks (click, shift-click,
|
||||||
// typed range, bulk ranges, cross-chapter spans), plus reorder and delete.
|
// typed range, bulk ranges, cross-chapter spans), plus reorder and delete.
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useToast } from '../components/Toasts.jsx';
|
||||||
import { bookOptions, CHAPTER_COUNTS, makeId, parseBibleChapter } from '../utils/bibleUtils.jsx';
|
import { bookOptions, CHAPTER_COUNTS, makeId, parseBibleChapter } from '../utils/bibleUtils.jsx';
|
||||||
|
|
||||||
|
// Every chunk starts blank; all note fields exist from the start.
|
||||||
|
export function blankChunk(start, end, spilloverEndVerse = null) {
|
||||||
|
return {
|
||||||
|
id: makeId(),
|
||||||
|
startVerse: start,
|
||||||
|
endVerse: end,
|
||||||
|
spilloverEndVerse,
|
||||||
|
observation: '',
|
||||||
|
interpretation: '',
|
||||||
|
application: '',
|
||||||
|
crossReferences: [],
|
||||||
|
greekWords: [],
|
||||||
|
generalNotes: '',
|
||||||
|
episodeNumber: '',
|
||||||
|
episodeTitle: '',
|
||||||
|
finalScript: '',
|
||||||
|
tags: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const joinText = (a, b) => [a, b].filter((s) => (s ?? '').trim()).join('\n\n');
|
||||||
|
|
||||||
|
// Merges chunk `b` (the one after `a`) into `a`, keeping a's id and combining notes,
|
||||||
|
// words, cross-references and tags so joining never loses anything.
|
||||||
|
export function mergeChunks(a, b) {
|
||||||
|
const uniq = (list) => [...new Set(list)];
|
||||||
|
return {
|
||||||
|
...a,
|
||||||
|
endVerse: b.endVerse,
|
||||||
|
spilloverEndVerse: b.spilloverEndVerse ?? null,
|
||||||
|
observation: joinText(a.observation, b.observation),
|
||||||
|
interpretation: joinText(a.interpretation, b.interpretation),
|
||||||
|
application: joinText(a.application, b.application),
|
||||||
|
generalNotes: joinText(a.generalNotes, b.generalNotes),
|
||||||
|
finalScript: joinText(a.finalScript, b.finalScript),
|
||||||
|
crossReferences: uniq([...(a.crossReferences ?? []), ...(b.crossReferences ?? [])]),
|
||||||
|
greekWords: [...(a.greekWords ?? []), ...(b.greekWords ?? [])],
|
||||||
|
tags: uniq([...(a.tags ?? []), ...(b.tags ?? [])]),
|
||||||
|
episodeNumber: a.episodeNumber || b.episodeNumber || '',
|
||||||
|
episodeTitle: a.episodeTitle || b.episodeTitle || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verse numbers where a section heading starts, from a HelloAO chapter payload.
|
||||||
|
export function headingStarts(chapterData) {
|
||||||
|
const starts = [];
|
||||||
|
let pending = false;
|
||||||
|
for (const item of chapterData?.chapter?.content ?? []) {
|
||||||
|
if (item.type === 'heading') pending = true;
|
||||||
|
else if (item.type === 'verse') {
|
||||||
|
if (pending) starts.push(item.number);
|
||||||
|
pending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return starts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uncovered runs of verses inside [from, to], given the chunks already there.
|
||||||
|
export function uncoveredRuns(chunks, from, to) {
|
||||||
|
const covered = new Set();
|
||||||
|
chunks.forEach((c) => { for (let v = c.startVerse; v <= c.endVerse; v += 1) covered.add(v); });
|
||||||
|
const runs = [];
|
||||||
|
let start = null;
|
||||||
|
for (let v = from; v <= to + 1; v += 1) {
|
||||||
|
const free = v <= to && !covered.has(v);
|
||||||
|
if (free && start === null) start = v;
|
||||||
|
if (!free && start !== null) { runs.push([start, v - 1]); start = null; }
|
||||||
|
}
|
||||||
|
return runs;
|
||||||
|
}
|
||||||
|
|
||||||
// A new project starts on Titus 1. When adding to an open project, start on
|
// A new project starts on Titus 1. When adding to an open project, start on
|
||||||
// the chapter after its last one (same book and translation).
|
// the chapter after its last one (same book and translation).
|
||||||
function initialSetupForm(project) {
|
function initialSetupForm(project) {
|
||||||
@@ -28,6 +100,7 @@ function initialChapterIndex(project) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useChunkBuilder({ project, setProject, updateProject, allChunks, setCurrentPage }) {
|
export function useChunkBuilder({ project, setProject, updateProject, allChunks, setCurrentPage }) {
|
||||||
|
const { toast } = useToast();
|
||||||
const [setup, setSetup] = useState(() => initialSetupForm(project));
|
const [setup, setSetup] = useState(() => initialSetupForm(project));
|
||||||
const [titleEdited, setTitleEdited] = useState(false);
|
const [titleEdited, setTitleEdited] = useState(false);
|
||||||
const [activeChapterIndex, setActiveChapterIndex] = useState(() => initialChapterIndex(project));
|
const [activeChapterIndex, setActiveChapterIndex] = useState(() => initialChapterIndex(project));
|
||||||
@@ -228,18 +301,7 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
|
|||||||
skippedCount += 1;
|
skippedCount += 1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const newChunk = {
|
const newChunk = blankChunk(start, end, spilloverEndVerse);
|
||||||
id: makeId(),
|
|
||||||
startVerse: start,
|
|
||||||
endVerse: end,
|
|
||||||
spilloverEndVerse,
|
|
||||||
observation: '',
|
|
||||||
interpretation: '',
|
|
||||||
application: '',
|
|
||||||
crossReferences: [],
|
|
||||||
greekWords: [],
|
|
||||||
tags: [],
|
|
||||||
};
|
|
||||||
existing.add(key);
|
existing.add(key);
|
||||||
newChunks.push(newChunk);
|
newChunks.push(newChunk);
|
||||||
addedCount += 1;
|
addedCount += 1;
|
||||||
@@ -471,7 +533,33 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Deleting a chunk also deletes its notes, so it can be taken back for a few seconds.
|
||||||
const deleteChunk = (chunkId) => {
|
const deleteChunk = (chunkId) => {
|
||||||
|
let removed = null;
|
||||||
|
(project?.chapters ?? []).forEach((ch, chapterIndex) => {
|
||||||
|
const index = ch.chunks.findIndex((c) => c.id === chunkId);
|
||||||
|
if (index >= 0) removed = { chunk: ch.chunks[index], chapterIndex, index, wasSelected: project.selectedChunkId === chunkId };
|
||||||
|
});
|
||||||
|
if (removed) {
|
||||||
|
toast('Chunk deleted', {
|
||||||
|
action: {
|
||||||
|
label: 'Undo',
|
||||||
|
onClick: () => updateProject((current) => {
|
||||||
|
if (current.chapters.some((ch) => ch.chunks.some((c) => c.id === chunkId))) return current;
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
selectedChunkId: removed.wasSelected ? chunkId : current.selectedChunkId,
|
||||||
|
chapters: current.chapters.map((ch, i) => {
|
||||||
|
if (i !== removed.chapterIndex) return ch;
|
||||||
|
const chunks = [...ch.chunks];
|
||||||
|
chunks.splice(Math.min(removed.index, chunks.length), 0, removed.chunk);
|
||||||
|
return { ...ch, chunks };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
updateProject((current) => {
|
updateProject((current) => {
|
||||||
const newAllChunks = current.chapters
|
const newAllChunks = current.chapters
|
||||||
.flatMap((ch) => ch.chunks)
|
.flatMap((ch) => ch.chunks)
|
||||||
@@ -491,6 +579,84 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Splits the chunk containing verse `n` into [start..n] and [n+1..end] (the first keeps
|
||||||
|
// its notes). If `n` isn't in any chunk yet, makes one from the last covered verse to `n`.
|
||||||
|
const splitAfterVerse = (n) => {
|
||||||
|
if (!activeChapter) return;
|
||||||
|
updateProject((current) => {
|
||||||
|
let selected = current.selectedChunkId;
|
||||||
|
const chapters = current.chapters.map((ch, idx) => {
|
||||||
|
if (idx !== activeChapterIndex) return ch;
|
||||||
|
const chunks = [...ch.chunks];
|
||||||
|
const i = chunks.findIndex((c) => c.startVerse <= n && n < c.endVerse && !c.spilloverEndVerse);
|
||||||
|
if (i >= 0) {
|
||||||
|
const c = chunks[i];
|
||||||
|
chunks[i] = { ...c, endVerse: n, spilloverEndVerse: null };
|
||||||
|
chunks.splice(i + 1, 0, blankChunk(n + 1, c.endVerse, c.spilloverEndVerse ?? null));
|
||||||
|
return { ...ch, chunks };
|
||||||
|
}
|
||||||
|
if (chunks.some((c) => c.startVerse <= n && n <= c.endVerse)) return ch; // already a boundary
|
||||||
|
const start = Math.max(0, ...chunks.filter((c) => c.endVerse < n).map((c) => c.endVerse)) + 1;
|
||||||
|
const created = blankChunk(start, n);
|
||||||
|
selected = created.id;
|
||||||
|
const at = chunks.findIndex((c) => c.startVerse > n);
|
||||||
|
chunks.splice(at < 0 ? chunks.length : at, 0, created);
|
||||||
|
return { ...ch, chunks };
|
||||||
|
});
|
||||||
|
return { ...current, chapters, selectedChunkId: selected };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Joins a chunk with the one right after it (adjacent verses), combining their notes.
|
||||||
|
const joinWithNext = (chunkId) => {
|
||||||
|
updateProject((current) => {
|
||||||
|
let selected = current.selectedChunkId;
|
||||||
|
const chapters = current.chapters.map((ch) => {
|
||||||
|
const i = ch.chunks.findIndex((c) => c.id === chunkId);
|
||||||
|
if (i < 0 || i >= ch.chunks.length - 1) return ch;
|
||||||
|
const a = ch.chunks[i];
|
||||||
|
const b = ch.chunks[i + 1];
|
||||||
|
if (a.endVerse + 1 !== b.startVerse) return ch;
|
||||||
|
if (selected === b.id) selected = a.id;
|
||||||
|
const chunks = [...ch.chunks];
|
||||||
|
chunks.splice(i, 2, mergeChunks(a, b));
|
||||||
|
return { ...ch, chunks };
|
||||||
|
});
|
||||||
|
return { ...current, chapters, selectedChunkId: selected };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fills verses that aren't in a chunk yet: one chunk per section heading (or every 8
|
||||||
|
// verses when the text has no headings). Chunks you already made are left alone.
|
||||||
|
const [suggesting, setSuggesting] = useState(false);
|
||||||
|
const suggestChunks = async () => {
|
||||||
|
if (!activeChapter) return;
|
||||||
|
const notify = (message, ms = 2400) => { setStatusMessage(message); window.setTimeout(() => setStatusMessage(''), ms); };
|
||||||
|
const last = activeChapter.verses.at(-1)?.number ?? 0;
|
||||||
|
setSuggesting(true);
|
||||||
|
try {
|
||||||
|
let starts = [];
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://bible.helloao.org/api/${project?.translation ?? 'BSB'}/${activeChapter.bookAbbrev}/${activeChapter.chapter}.json`);
|
||||||
|
if (res.ok) starts = headingStarts(await res.json());
|
||||||
|
} catch { /* headings are a nicety — fall back to even chunks */ }
|
||||||
|
const usedHeadings = starts.length > 0;
|
||||||
|
const first = activeChapter.verses[0]?.number ?? 1;
|
||||||
|
if (!usedHeadings) for (let v = first; v <= last; v += 8) starts.push(v);
|
||||||
|
const bounds = [...new Set([first, ...starts])].sort((a, b) => a - b);
|
||||||
|
const ranges = [];
|
||||||
|
bounds.forEach((from, k) => {
|
||||||
|
const to = (bounds[k + 1] ?? last + 1) - 1;
|
||||||
|
uncoveredRuns(activeChapter.chunks, from, to).forEach(([s, e]) => ranges.push({ start: s, end: e }));
|
||||||
|
});
|
||||||
|
if (ranges.length === 0) { notify('Every verse is already in a chunk.'); return; }
|
||||||
|
const { addedCount } = addChunkRanges(ranges);
|
||||||
|
notify(`Added ${addedCount} chunk${addedCount === 1 ? '' : 's'} ${usedHeadings ? 'from section headings' : 'of about 8 verses'}.`);
|
||||||
|
} finally {
|
||||||
|
setSuggesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
setup,
|
setup,
|
||||||
titleEdited, setTitleEdited,
|
titleEdited, setTitleEdited,
|
||||||
@@ -517,5 +683,9 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
|
|||||||
addClickSpanChunk,
|
addClickSpanChunk,
|
||||||
moveChunk,
|
moveChunk,
|
||||||
deleteChunk,
|
deleteChunk,
|
||||||
|
splitAfterVerse,
|
||||||
|
joinWithNext,
|
||||||
|
suggestChunks,
|
||||||
|
suggesting,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { renderHook, act } from '@testing-library/react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { vi, test, expect, beforeEach } from 'vitest';
|
||||||
|
import { blankChunk, headingStarts, mergeChunks, uncoveredRuns, useChunkBuilder } from './useChunkBuilder.js';
|
||||||
|
|
||||||
|
const c = (s, e) => ({ startVerse: s, endVerse: e });
|
||||||
|
|
||||||
|
test('uncoveredRuns lists the verse runs not yet in any chunk', () => {
|
||||||
|
expect(uncoveredRuns([c(1, 3), c(6, 7)], 1, 10)).toEqual([[4, 5], [8, 10]]);
|
||||||
|
expect(uncoveredRuns([], 5, 8)).toEqual([[5, 8]]);
|
||||||
|
expect(uncoveredRuns([c(1, 10)], 1, 10)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('headingStarts finds the verse each section heading precedes', () => {
|
||||||
|
const data = { chapter: { content: [
|
||||||
|
{ type: 'heading' }, { type: 'verse', number: 1 }, { type: 'verse', number: 2 },
|
||||||
|
{ type: 'heading' }, { type: 'line_break' }, { type: 'verse', number: 22 },
|
||||||
|
] } };
|
||||||
|
expect(headingStarts(data)).toEqual([1, 22]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mergeChunks keeps the first id and combines notes, words, refs and tags', () => {
|
||||||
|
const a = { ...blankChunk(1, 3), observation: 'A', tags: ['x'], crossReferences: ['John 1:1'], greekWords: [{ id: 'w1' }] };
|
||||||
|
const b = { ...blankChunk(4, 6), observation: 'B', tags: ['x', 'y'], crossReferences: ['John 1:1', 'Rom 3:23'], greekWords: [{ id: 'w2' }], episodeTitle: 'Ep' };
|
||||||
|
const m = mergeChunks(a, b);
|
||||||
|
expect([m.id, m.startVerse, m.endVerse]).toEqual([a.id, 1, 6]);
|
||||||
|
expect(m.observation).toBe('A\n\nB');
|
||||||
|
expect(m.tags).toEqual(['x', 'y']);
|
||||||
|
expect(m.crossReferences).toEqual(['John 1:1', 'Rom 3:23']);
|
||||||
|
expect(m.greekWords.map((w) => w.id)).toEqual(['w1', 'w2']);
|
||||||
|
expect(m.episodeTitle).toBe('Ep');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- hook operations against a small in-memory project -----------------------
|
||||||
|
const verses = Array.from({ length: 12 }, (_, i) => ({ number: i + 1, text: `verse ${i + 1}` }));
|
||||||
|
const makeProject = (chunks = []) => ({
|
||||||
|
id: 'p', title: 'T', translation: 'BSB', selectedChunkId: null,
|
||||||
|
chapters: [{ book: 'Titus', bookAbbrev: 'TIT', chapter: '1', verses, chunks }],
|
||||||
|
});
|
||||||
|
|
||||||
|
function setup(initialChunks) {
|
||||||
|
return renderHook(() => {
|
||||||
|
const [project, setProject] = useState(makeProject(initialChunks));
|
||||||
|
const updateProject = (fn) => setProject((cur) => fn(cur));
|
||||||
|
const allChunks = project.chapters.flatMap((ch) => ch.chunks);
|
||||||
|
const builder = useChunkBuilder({ project, setProject, updateProject, allChunks, setCurrentPage: () => {} });
|
||||||
|
return { project, builder };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const ranges = (h) => h.result.current.project.chapters[0].chunks.map((k) => [k.startVerse, k.endVerse]);
|
||||||
|
|
||||||
|
beforeEach(() => { vi.restoreAllMocks(); });
|
||||||
|
|
||||||
|
test('splitting between uncovered verses builds chunks one after another', () => {
|
||||||
|
const h = setup();
|
||||||
|
act(() => h.result.current.builder.splitAfterVerse(4));
|
||||||
|
act(() => h.result.current.builder.splitAfterVerse(9));
|
||||||
|
expect(ranges(h)).toEqual([[1, 4], [5, 9]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('splitting inside a chunk keeps the notes on the first part', () => {
|
||||||
|
const original = { ...blankChunk(1, 8), observation: 'keep me' };
|
||||||
|
const h = setup([original]);
|
||||||
|
act(() => h.result.current.builder.splitAfterVerse(3));
|
||||||
|
const [first, second] = h.result.current.project.chapters[0].chunks;
|
||||||
|
expect([first.id, first.startVerse, first.endVerse, first.observation]).toEqual([original.id, 1, 3, 'keep me']);
|
||||||
|
expect([second.startVerse, second.endVerse, second.observation]).toEqual([4, 8, '']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('splitting where a boundary already exists changes nothing', () => {
|
||||||
|
const h = setup([blankChunk(1, 4), blankChunk(5, 8)]);
|
||||||
|
act(() => h.result.current.builder.splitAfterVerse(4));
|
||||||
|
expect(ranges(h)).toEqual([[1, 4], [5, 8]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('joining merges a chunk with the next one and re-selects the survivor', () => {
|
||||||
|
const a = { ...blankChunk(1, 4), observation: 'one' };
|
||||||
|
const b = { ...blankChunk(5, 8), observation: 'two' };
|
||||||
|
const h = setup([a, b]);
|
||||||
|
act(() => h.result.current.builder.joinWithNext(a.id));
|
||||||
|
const [only] = h.result.current.project.chapters[0].chunks;
|
||||||
|
expect([only.id, only.startVerse, only.endVerse, only.observation]).toEqual([a.id, 1, 8, 'one\n\ntwo']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('joining is refused when the chunks are not adjacent', () => {
|
||||||
|
const a = blankChunk(1, 3);
|
||||||
|
const h = setup([a, blankChunk(6, 8)]);
|
||||||
|
act(() => h.result.current.builder.joinWithNext(a.id));
|
||||||
|
expect(ranges(h)).toEqual([[1, 3], [6, 8]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('suggest from headings fills only the uncovered verses, one chunk per section', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ chapter: { content: [
|
||||||
|
{ type: 'heading' }, { type: 'verse', number: 1 }, { type: 'heading' }, { type: 'verse', number: 5 }, { type: 'heading' }, { type: 'verse', number: 9 },
|
||||||
|
] } }),
|
||||||
|
})));
|
||||||
|
const h = setup([blankChunk(5, 6)]); // verses 5-6 already done
|
||||||
|
await act(async () => { await h.result.current.builder.suggestChunks(); });
|
||||||
|
expect(ranges(h).sort((x, y) => x[0] - y[0])).toEqual([[1, 4], [5, 6], [7, 8], [9, 12]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('suggest falls back to even chunks when the chapter has no headings', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ chapter: { content: [{ type: 'verse', number: 1 }] } }) })));
|
||||||
|
const h = setup();
|
||||||
|
await act(async () => { await h.result.current.builder.suggestChunks(); });
|
||||||
|
expect(ranges(h)).toEqual([[1, 8], [9, 12]]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// Makes the browser's (and phone's) Back/Forward buttons move between the app's pages. The app keeps
|
||||||
|
// its page in state rather than in the URL, so without this Back would leave the app altogether.
|
||||||
|
// Each page change adds a history entry; Back/Forward set the page again from that entry.
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
// Pages that only make sense with a study project open.
|
||||||
|
export const NEEDS_PROJECT = ['study', 'outline', 'script', 'export'];
|
||||||
|
|
||||||
|
export function useHistoryNav({ page, setPage, hasProject, onArrive, enabled = true }) {
|
||||||
|
const shown = useRef(page);
|
||||||
|
const hasProjectRef = useRef(hasProject);
|
||||||
|
hasProjectRef.current = hasProject;
|
||||||
|
const onArriveRef = useRef(onArrive);
|
||||||
|
onArriveRef.current = onArrive;
|
||||||
|
|
||||||
|
// First entry: record where the app started so Back can return to it.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
try { window.history.replaceState({ page }, ''); } catch { /* history unavailable */ }
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// A page change made inside the app adds a history entry.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || shown.current === page) return;
|
||||||
|
shown.current = page;
|
||||||
|
try { window.history.pushState({ page }, ''); } catch { /* history unavailable */ }
|
||||||
|
}, [page, enabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return undefined;
|
||||||
|
const onPop = (e) => {
|
||||||
|
let target = e.state?.page;
|
||||||
|
if (!target) return; // an entry that isn't ours (e.g. a hash change)
|
||||||
|
if (NEEDS_PROJECT.includes(target) && !hasProjectRef.current) target = 'home';
|
||||||
|
shown.current = target; // so the effect above doesn't push this page again
|
||||||
|
setPage(target);
|
||||||
|
onArriveRef.current?.(target);
|
||||||
|
};
|
||||||
|
window.addEventListener('popstate', onPop);
|
||||||
|
return () => window.removeEventListener('popstate', onPop);
|
||||||
|
}, [enabled, setPage]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// Drives "Save for offline": one download at a time, with progress for the button to show,
|
||||||
|
// a cancel, and a toast when it finishes.
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { useToast } from '../components/Toasts.jsx';
|
||||||
|
import { bibleRefs, bookRefs, downloadChapters, loadSavedBooks, markBookSaved } from '../utils/offline.js';
|
||||||
|
|
||||||
|
export function useOnline() {
|
||||||
|
const [online, setOnline] = useState(() => (typeof navigator === 'undefined' ? true : navigator.onLine !== false));
|
||||||
|
useEffect(() => {
|
||||||
|
const up = () => setOnline(true);
|
||||||
|
const down = () => setOnline(false);
|
||||||
|
window.addEventListener('online', up);
|
||||||
|
window.addEventListener('offline', down);
|
||||||
|
return () => { window.removeEventListener('online', up); window.removeEventListener('offline', down); };
|
||||||
|
}, []);
|
||||||
|
return online;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOfflineSave() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [saved, setSaved] = useState(loadSavedBooks);
|
||||||
|
const [job, setJob] = useState(null); // { label, done, total } while downloading
|
||||||
|
const controller = useRef(null);
|
||||||
|
useEffect(() => () => controller.current?.abort(), []);
|
||||||
|
|
||||||
|
const run = useCallback(async (label, translation, refs, { bookKeys = [] } = {}) => {
|
||||||
|
if (controller.current) return; // one at a time
|
||||||
|
const abort = new AbortController();
|
||||||
|
controller.current = abort;
|
||||||
|
setJob({ label, done: 0, total: refs.length });
|
||||||
|
const result = await downloadChapters(translation, refs, { signal: abort.signal, onProgress: (done, total) => setJob({ label, done, total }) });
|
||||||
|
controller.current = null;
|
||||||
|
setJob(null);
|
||||||
|
if (abort.signal.aborted) { toast('Download stopped'); return; }
|
||||||
|
if (result.failed === 0) {
|
||||||
|
bookKeys.forEach((b) => markBookSaved(translation, b));
|
||||||
|
setSaved(loadSavedBooks());
|
||||||
|
toast(`Saved ${label} for offline`);
|
||||||
|
} else {
|
||||||
|
toast(`Saved ${result.done} of ${refs.length} chapters — check your connection and try again`, { tone: 'error' });
|
||||||
|
}
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
saved, job,
|
||||||
|
isSaved: (translation, book) => (saved[translation] ?? []).includes(book),
|
||||||
|
saveBook: (translation, book, name) => run(name, translation, bookRefs(book), { bookKeys: [book] }),
|
||||||
|
saveBible: (translation) => run(`The whole ${translation}`, translation, bibleRefs(), { bookKeys: [...new Set(bibleRefs().map((r) => r.book))] }),
|
||||||
|
saveRefs: (label, translation, refs) => run(label, translation, refs),
|
||||||
|
cancel: () => controller.current?.abort(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// The second Bible in the side-by-side view: loads the same chapter in another translation.
|
||||||
|
// Pass an empty translation to switch it off.
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { fetchChapter } from '../utils/bibleText.js';
|
||||||
|
|
||||||
|
export function useParallelChapter(translation, bookAbbrev, chapter) {
|
||||||
|
const [state, setState] = useState({ key: '', verses: [], error: '' });
|
||||||
|
const key = translation ? `${translation}/${bookAbbrev}/${chapter}` : '';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!key) return undefined;
|
||||||
|
const controller = new AbortController();
|
||||||
|
fetchChapter(translation, bookAbbrev, chapter, { signal: controller.signal })
|
||||||
|
.then(({ verses }) => setState({ key, verses, error: '' }))
|
||||||
|
.catch((err) => { if (!controller.signal.aborted) setState({ key, verses: [], error: err.message || 'Unable to load.' }); });
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [key]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const ready = state.key === key;
|
||||||
|
return {
|
||||||
|
verses: ready ? state.verses : [],
|
||||||
|
error: ready ? state.error : '',
|
||||||
|
loading: !!key && !ready,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// Keeps a few display preferences (theme, default translation, verse layout, commentary
|
||||||
|
// source, and where you left off in the Reader) in step with the signed-in account.
|
||||||
|
// Every preference has its own timestamp, so the newest change to each one wins and two
|
||||||
|
// devices changing different settings never overwrite each other.
|
||||||
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { getRemotePrefs, saveRemotePrefs } from '../syncService.js';
|
||||||
|
|
||||||
|
export const PREF_KEYS = ['theme', 'translation', 'layout', 'commentarySource', 'planTranslation', 'startPage', 'position', 'colorLabels'];
|
||||||
|
const META_KEY = 'prefs-meta'; // { key: timestamp of the last change made on this device }
|
||||||
|
const PUSH_DELAY_MS = 1000;
|
||||||
|
const FOCUS_PULL_MIN_MS = 30_000;
|
||||||
|
|
||||||
|
const lsGet = (k) => { try { return localStorage.getItem(k); } catch { return null; } };
|
||||||
|
const lsSet = (k, v) => { try { localStorage.setItem(k, v); } catch { /* storage unavailable */ } };
|
||||||
|
const lsDel = (k) => { try { localStorage.removeItem(k); } catch { /* storage unavailable */ } };
|
||||||
|
const readMeta = () => { try { return JSON.parse(lsGet(META_KEY) || '{}'); } catch { return {}; } };
|
||||||
|
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
||||||
|
|
||||||
|
// values: { theme, translation, layout, commentarySource, planTranslation, startPage, position: { book, chapter } }
|
||||||
|
// apply(key, value): puts a server value on screen and in localStorage
|
||||||
|
// canApplyPosition(): false while the Reader is open, so a background sync never moves the page under you
|
||||||
|
export function usePrefsSync({ values, apply, canApplyPosition = () => true }) {
|
||||||
|
const valuesRef = useRef(values);
|
||||||
|
valuesRef.current = values;
|
||||||
|
const prevRef = useRef(values);
|
||||||
|
const enabledRef = useRef(false);
|
||||||
|
const dirtyRef = useRef(new Set());
|
||||||
|
const timerRef = useRef(null);
|
||||||
|
const lastPullRef = useRef(0);
|
||||||
|
const applyRef = useRef(apply);
|
||||||
|
applyRef.current = apply;
|
||||||
|
const canApplyPositionRef = useRef(canApplyPosition);
|
||||||
|
canApplyPositionRef.current = canApplyPosition;
|
||||||
|
|
||||||
|
const push = useCallback(async () => {
|
||||||
|
window.clearTimeout(timerRef.current);
|
||||||
|
if (!enabledRef.current || dirtyRef.current.size === 0) return;
|
||||||
|
const keys = [...dirtyRef.current];
|
||||||
|
const meta = readMeta();
|
||||||
|
const prefs = Object.fromEntries(keys.map((k) => [k, { v: valuesRef.current[k], at: meta[k] ?? Date.now() }]));
|
||||||
|
const result = await saveRemotePrefs(prefs);
|
||||||
|
if (!result.ok) return; // stays dirty; retried on the next change or when the tab regains focus
|
||||||
|
keys.forEach((k) => { if (readMeta()[k] === prefs[k].at) dirtyRef.current.delete(k); });
|
||||||
|
if (dirtyRef.current.size > 0) timerRef.current = window.setTimeout(push, PUSH_DELAY_MS);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Local changes: stamp them and save shortly.
|
||||||
|
useEffect(() => {
|
||||||
|
const prev = prevRef.current;
|
||||||
|
const changed = PREF_KEYS.filter((k) => !same(prev[k], values[k]));
|
||||||
|
prevRef.current = values;
|
||||||
|
if (changed.length === 0) return;
|
||||||
|
const meta = readMeta();
|
||||||
|
const now = Date.now();
|
||||||
|
changed.forEach((k) => { meta[k] = now; dirtyRef.current.add(k); });
|
||||||
|
lsSet(META_KEY, JSON.stringify(meta));
|
||||||
|
if (enabledRef.current) {
|
||||||
|
window.clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = window.setTimeout(push, PUSH_DELAY_MS);
|
||||||
|
}
|
||||||
|
}, [values.theme, values.translation, values.layout, values.commentarySource, values.planTranslation, values.startPage, values.position.book, values.position.chapter, values.colorLabels, push]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const pullServerPrefs = useCallback(async () => {
|
||||||
|
enabledRef.current = true;
|
||||||
|
lastPullRef.current = Date.now();
|
||||||
|
const result = await getRemotePrefs();
|
||||||
|
if (!result.ok) return;
|
||||||
|
const server = result.data?.prefs ?? {};
|
||||||
|
const meta = readMeta();
|
||||||
|
const now = Date.now();
|
||||||
|
for (const key of PREF_KEYS) {
|
||||||
|
if (valuesRef.current[key] === undefined) continue; // nothing on this device to compare or upload
|
||||||
|
const remote = server[key];
|
||||||
|
const localAt = meta[key] ?? 0;
|
||||||
|
if (remote && remote.at > localAt) {
|
||||||
|
if (key === 'position' && !canApplyPositionRef.current()) continue; // don't move the page mid-read
|
||||||
|
meta[key] = remote.at;
|
||||||
|
if (!same(valuesRef.current[key], remote.v)) {
|
||||||
|
prevRef.current = { ...prevRef.current, [key]: remote.v }; // so this isn't mistaken for a local edit
|
||||||
|
applyRef.current(key, remote.v);
|
||||||
|
}
|
||||||
|
dirtyRef.current.delete(key);
|
||||||
|
} else if (!remote || localAt > remote.at) {
|
||||||
|
if (!meta[key]) meta[key] = now; // first time on this account: what's on screen becomes the account's
|
||||||
|
dirtyRef.current.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lsSet(META_KEY, JSON.stringify(meta));
|
||||||
|
await push();
|
||||||
|
}, [push]);
|
||||||
|
|
||||||
|
// Signed out: stop syncing. Values stay as they are on this device; the timestamps are
|
||||||
|
// forgotten so the next account to sign in here starts from its own settings.
|
||||||
|
const stopPrefsSync = useCallback(() => {
|
||||||
|
enabledRef.current = false;
|
||||||
|
window.clearTimeout(timerRef.current);
|
||||||
|
dirtyRef.current.clear();
|
||||||
|
lsDel(META_KEY);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onVisibility = () => {
|
||||||
|
if (!enabledRef.current) return;
|
||||||
|
if (document.visibilityState === 'hidden') { push(); return; }
|
||||||
|
if (Date.now() - lastPullRef.current > FOCUS_PULL_MIN_MS) pullServerPrefs();
|
||||||
|
};
|
||||||
|
document.addEventListener('visibilitychange', onVisibility);
|
||||||
|
return () => document.removeEventListener('visibilitychange', onVisibility);
|
||||||
|
}, [push, pullServerPrefs]);
|
||||||
|
|
||||||
|
useEffect(() => () => window.clearTimeout(timerRef.current), []);
|
||||||
|
|
||||||
|
return { pullServerPrefs, stopPrefsSync, flushPrefs: push };
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { renderHook, act } from '@testing-library/react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { vi, test, expect, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
const remote = { prefs: {}, puts: [] };
|
||||||
|
vi.mock('../syncService.js', () => ({
|
||||||
|
getRemotePrefs: vi.fn(async () => ({ ok: true, data: { prefs: remote.prefs } })),
|
||||||
|
saveRemotePrefs: vi.fn(async (prefs) => { remote.puts.push(prefs); return { ok: true, data: { prefs } }; }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { usePrefsSync } from './usePrefsSync.js';
|
||||||
|
|
||||||
|
beforeEach(() => { localStorage.clear(); remote.prefs = {}; remote.puts = []; });
|
||||||
|
|
||||||
|
// A stand-in for the Reader's state, wired the same way useBibleReader wires it.
|
||||||
|
function setup({ canApplyPosition = () => true, initial = {} } = {}) {
|
||||||
|
return renderHook(() => {
|
||||||
|
const [theme, setTheme] = useState(initial.theme ?? 'paper');
|
||||||
|
const [layout, setLayout] = useState(initial.layout ?? 'flow');
|
||||||
|
const [position, setPosition] = useState(initial.position ?? { book: 'GEN', chapter: 1 });
|
||||||
|
const sync = usePrefsSync({
|
||||||
|
values: { theme, translation: 'BSB', layout, commentarySource: 'matthew-henry', planTranslation: 'same', startPage: 'studies', position },
|
||||||
|
apply: (key, v) => { if (key === 'theme') setTheme(v); else if (key === 'layout') setLayout(v); else if (key === 'position') setPosition(v); },
|
||||||
|
canApplyPosition,
|
||||||
|
});
|
||||||
|
return { theme, layout, position, setTheme, setLayout, setPosition, ...sync };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the server\'s newer value wins, an older one does not', async () => {
|
||||||
|
localStorage.setItem('prefs-meta', JSON.stringify({ theme: 500, layout: 900 }));
|
||||||
|
remote.prefs = { theme: { v: 'dark', at: 800 }, layout: { v: 'verses', at: 100 } };
|
||||||
|
const h = setup();
|
||||||
|
await act(async () => { await h.result.current.pullServerPrefs(); });
|
||||||
|
expect(h.result.current.theme).toBe('dark'); // server is newer
|
||||||
|
expect(h.result.current.layout).toBe('flow'); // this device changed it later
|
||||||
|
expect(remote.puts).toHaveLength(1);
|
||||||
|
expect(Object.keys(remote.puts[0])).toContain('layout'); // ...so the server hears about it
|
||||||
|
expect(Object.keys(remote.puts[0])).not.toContain('theme');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a device with no history takes the account\'s settings; a first device uploads its own', async () => {
|
||||||
|
remote.prefs = { theme: { v: 'sepia', at: 700 } };
|
||||||
|
const h = setup();
|
||||||
|
await act(async () => { await h.result.current.pullServerPrefs(); });
|
||||||
|
expect(h.result.current.theme).toBe('sepia');
|
||||||
|
// keys the account doesn't have yet are filled in from this device
|
||||||
|
expect(Object.keys(remote.puts[0]).sort()).toEqual(['commentarySource', 'layout', 'planTranslation', 'position', 'startPage', 'translation']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reading position is not moved under you while the Reader is open', async () => {
|
||||||
|
remote.prefs = { position: { v: { book: 'JHN', chapter: 3 }, at: 900 }, theme: { v: 'dark', at: 900 } };
|
||||||
|
const h = setup({ canApplyPosition: () => false });
|
||||||
|
await act(async () => { await h.result.current.pullServerPrefs(); });
|
||||||
|
expect(h.result.current.position).toEqual({ book: 'GEN', chapter: 1 }); // stays put
|
||||||
|
expect(h.result.current.theme).toBe('dark'); // everything else still syncs
|
||||||
|
});
|
||||||
|
|
||||||
|
test('and it is applied when the Reader is not open', async () => {
|
||||||
|
remote.prefs = { position: { v: { book: 'JHN', chapter: 3 }, at: 900 } };
|
||||||
|
const h = setup({ canApplyPosition: () => true });
|
||||||
|
await act(async () => { await h.result.current.pullServerPrefs(); });
|
||||||
|
expect(h.result.current.position).toEqual({ book: 'JHN', chapter: 3 });
|
||||||
|
expect(remote.puts.every((p) => !('position' in p))).toBe(true); // applying it isn't mistaken for a local edit
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a local change is stamped and pushed shortly after', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const h = setup();
|
||||||
|
await act(async () => { await h.result.current.pullServerPrefs(); });
|
||||||
|
remote.puts = [];
|
||||||
|
act(() => h.result.current.setTheme('dark'));
|
||||||
|
await act(async () => { await vi.advanceTimersByTimeAsync(1200); });
|
||||||
|
expect(remote.puts).toHaveLength(1);
|
||||||
|
expect(remote.puts[0].theme.v).toBe('dark');
|
||||||
|
expect(JSON.parse(localStorage.getItem('prefs-meta')).theme).toBe(remote.puts[0].theme.at);
|
||||||
|
} finally { vi.useRealTimers(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stopping sync forgets the timestamps so the next account starts from its own settings', async () => {
|
||||||
|
const h = setup();
|
||||||
|
await act(async () => { await h.result.current.pullServerPrefs(); });
|
||||||
|
expect(localStorage.getItem('prefs-meta')).not.toBeNull();
|
||||||
|
act(() => h.result.current.stopPrefsSync());
|
||||||
|
expect(localStorage.getItem('prefs-meta')).toBeNull();
|
||||||
|
});
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
// The signed-in user's saved projects: opening, renaming, deleting, and keeping
|
// The signed-in user's saved projects: opening, renaming, deleting, and keeping
|
||||||
// this device's copies in step with the server.
|
// this device's copies in step with the server.
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useToast } from '../components/Toasts.jsx';
|
||||||
|
import { clearSyncBase, getSyncBase, setSyncBase } from '../utils/syncBase.js';
|
||||||
import { loadProjectIndex } from '../utils/bibleUtils.jsx';
|
import { loadProjectIndex } from '../utils/bibleUtils.jsx';
|
||||||
import {
|
import {
|
||||||
saveProjectToStorage,
|
saveProjectToStorage,
|
||||||
@@ -21,6 +23,9 @@ export function useProjectLibrary({
|
|||||||
setProjectIndex,
|
setProjectIndex,
|
||||||
setCurrentPage,
|
setCurrentPage,
|
||||||
}) {
|
}) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
// Deleted projects waiting out their "Undo" window before the server copy is removed too
|
||||||
|
const pendingDeletes = useRef(new Map()); // id -> timer
|
||||||
const [staleLocalProjects, setStaleLocalProjects] = useState([]); // projects where server is newer
|
const [staleLocalProjects, setStaleLocalProjects] = useState([]); // projects where server is newer
|
||||||
const [autoRestoredCount, setAutoRestoredCount] = useState(null); // brief "synced from another device" toast
|
const [autoRestoredCount, setAutoRestoredCount] = useState(null); // brief "synced from another device" toast
|
||||||
|
|
||||||
@@ -39,11 +44,17 @@ export function useProjectLibrary({
|
|||||||
listRemoteProjects().then(async (result) => {
|
listRemoteProjects().then(async (result) => {
|
||||||
if (!result.ok || cancelled) return;
|
if (!result.ok || cancelled) return;
|
||||||
const localMap = new Map(localIndex.map((e) => [e.id, e]));
|
const localMap = new Map(localIndex.map((e) => [e.id, e]));
|
||||||
|
// What this device has seen of each study on the server: a save made on top of anything older is
|
||||||
|
// checked against it, so a newer copy from another device is never overwritten silently.
|
||||||
|
for (const e of result.data) {
|
||||||
|
const local = localMap.get(e.id);
|
||||||
|
setSyncBase(e.id, local ? Math.min(e.lastEdited ?? 0, local.lastEdited ?? 0) : e.lastEdited);
|
||||||
|
}
|
||||||
|
|
||||||
const missing = result.data.filter((e) => !localMap.has(e.id));
|
const missing = result.data.filter((e) => !localMap.has(e.id));
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
const fetched = await Promise.all(missing.map((e) => loadRemoteProject(e.id)));
|
const fetched = await Promise.all(missing.map((e) => loadRemoteProject(e.id)));
|
||||||
fetched.filter((r) => r.ok).forEach((r) => saveProjectToStorage(r.data));
|
fetched.filter((r) => r.ok).forEach((r) => { saveProjectToStorage(r.data); setSyncBase(r.data.id, r.data.lastEdited); });
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const restoredCount = fetched.filter((r) => r.ok).length;
|
const restoredCount = fetched.filter((r) => r.ok).length;
|
||||||
if (restoredCount > 0) {
|
if (restoredCount > 0) {
|
||||||
@@ -78,26 +89,57 @@ export function useProjectLibrary({
|
|||||||
const updated = { ...loaded, title, lastEdited: Date.now() };
|
const updated = { ...loaded, title, lastEdited: Date.now() };
|
||||||
saveProjectToStorage(updated);
|
saveProjectToStorage(updated);
|
||||||
setProjectIndex(loadProjectIndex());
|
setProjectIndex(loadProjectIndex());
|
||||||
saveRemoteProject(updated);
|
saveRemoteProject(updated, { baseEdited: getSyncBase(id) }).then((result) => {
|
||||||
|
if (result.ok) setSyncBase(id, updated.lastEdited);
|
||||||
|
else if (result.status === 409) toast('That study changed on another device. Open it to choose which version to keep.', { tone: 'error' });
|
||||||
|
});
|
||||||
if (project?.id === id) {
|
if (project?.id === id) {
|
||||||
setProject((current) => (current ? { ...current, title } : current));
|
setProject((current) => (current ? { ...current, title } : current));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteProject = (id) => {
|
// Deleting is immediate here, with an Undo for a few seconds; only after that is the server copy
|
||||||
if (!window.confirm('Delete this project? This cannot be undone.')) return;
|
// removed (and straight away if the tab is closed first, so a deleted project never comes back).
|
||||||
deleteProjectFromStorage(id);
|
const finishDelete = (id) => {
|
||||||
setProjectIndex(loadProjectIndex());
|
if (!pendingDeletes.current.has(id)) return;
|
||||||
|
window.clearTimeout(pendingDeletes.current.get(id));
|
||||||
|
pendingDeletes.current.delete(id);
|
||||||
deleteRemoteProject(id); // fire-and-forget
|
deleteRemoteProject(id); // fire-and-forget
|
||||||
};
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
const flush = () => [...pendingDeletes.current.keys()].forEach(finishDelete);
|
||||||
|
window.addEventListener('pagehide', flush);
|
||||||
|
return () => window.removeEventListener('pagehide', flush);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const deleteProject = (id) => {
|
||||||
|
const loaded = loadProjectById(id);
|
||||||
|
if (!loaded) return;
|
||||||
|
deleteProjectFromStorage(id);
|
||||||
|
clearSyncBase(id);
|
||||||
|
setProjectIndex(loadProjectIndex());
|
||||||
|
pendingDeletes.current.set(id, window.setTimeout(() => finishDelete(id), 8000));
|
||||||
|
toast(`Deleted “${loaded.title}”`, {
|
||||||
|
action: {
|
||||||
|
label: 'Undo',
|
||||||
|
onClick: () => {
|
||||||
|
window.clearTimeout(pendingDeletes.current.get(id));
|
||||||
|
pendingDeletes.current.delete(id);
|
||||||
|
saveProjectToStorage(loaded);
|
||||||
|
setProjectIndex(loadProjectIndex());
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const pullLatestFromServer = async (id) => {
|
const pullLatestFromServer = async (id) => {
|
||||||
const result = await loadRemoteProject(id);
|
const result = await loadRemoteProject(id);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
alert('Could not pull latest version from server.');
|
toast('Could not pull the latest version from the server.', { tone: 'error' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
saveProjectToStorage(result.data);
|
saveProjectToStorage(result.data);
|
||||||
|
setSyncBase(id, result.data.lastEdited);
|
||||||
setProjectIndex(loadProjectIndex());
|
setProjectIndex(loadProjectIndex());
|
||||||
setStaleLocalProjects((prev) => prev.filter((e) => e.id !== id));
|
setStaleLocalProjects((prev) => prev.filter((e) => e.id !== id));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// Keeps the Reader's bookmarks and text highlights in step with the signed-in user's
|
||||||
|
// account, so they follow you between devices. Local edits save to localStorage as they
|
||||||
|
// always did; while signed in they are also pushed to the server a moment after the last
|
||||||
|
// change, and pulled when you sign in and whenever you come back to the tab.
|
||||||
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { getRemoteMarks, saveRemoteMarks } from '../syncService.js';
|
||||||
|
import { emptyMarks, isEmptyMarks, marksEqual, mergeMarks, normalizeMarks } from '../utils/readerMarks.js';
|
||||||
|
|
||||||
|
const KEYS = { bookmarks: 'reader-bookmarks', highlights: 'readerTextHighlights', syncedAt: 'reader-marks-synced-at', dirty: 'reader-marks-dirty' };
|
||||||
|
const PUSH_DELAY_MS = 1500;
|
||||||
|
const FOCUS_PULL_MIN_MS = 20_000;
|
||||||
|
|
||||||
|
const lsGet = (k) => { try { return localStorage.getItem(k); } catch { return null; } };
|
||||||
|
const lsSet = (k, v) => { try { localStorage.setItem(k, v); } catch { /* storage unavailable */ } };
|
||||||
|
const lsDel = (k) => { try { localStorage.removeItem(k); } catch { /* storage unavailable */ } };
|
||||||
|
|
||||||
|
export function useReaderMarksSync({ bookmarks, highlights, setBookmarks, setHighlights }) {
|
||||||
|
const enabledRef = useRef(false);
|
||||||
|
const latestRef = useRef({ bookmarks, highlights });
|
||||||
|
latestRef.current = { bookmarks, highlights };
|
||||||
|
const timerRef = useRef(null);
|
||||||
|
const skipRef = useRef(false); // set while applying server data, so it isn't mistaken for a local edit
|
||||||
|
const firstRunRef = useRef(true);
|
||||||
|
const lastPullRef = useRef(0);
|
||||||
|
const pushingRef = useRef(null);
|
||||||
|
|
||||||
|
const setDirty = (dirty) => (dirty ? lsSet(KEYS.dirty, '1') : lsDel(KEYS.dirty));
|
||||||
|
|
||||||
|
const push = useCallback(async () => {
|
||||||
|
window.clearTimeout(timerRef.current);
|
||||||
|
if (!enabledRef.current) return;
|
||||||
|
if (pushingRef.current) { await pushingRef.current; }
|
||||||
|
const snapshot = latestRef.current;
|
||||||
|
pushingRef.current = saveRemoteMarks({ bookmarks: snapshot.bookmarks, highlights: snapshot.highlights });
|
||||||
|
const result = await pushingRef.current;
|
||||||
|
pushingRef.current = null;
|
||||||
|
if (!result.ok) return; // stays dirty; retried on the next change or when the tab regains focus
|
||||||
|
lsSet(KEYS.syncedAt, String(result.data.updatedAt));
|
||||||
|
// Only clean if nothing changed while the request was in flight
|
||||||
|
if (latestRef.current.bookmarks === snapshot.bookmarks && latestRef.current.highlights === snapshot.highlights) setDirty(false);
|
||||||
|
else timerRef.current = window.setTimeout(push, PUSH_DELAY_MS);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Local edits: remember there's something to save, and save it shortly.
|
||||||
|
useEffect(() => {
|
||||||
|
if (firstRunRef.current) { firstRunRef.current = false; return; }
|
||||||
|
if (skipRef.current) { skipRef.current = false; return; }
|
||||||
|
setDirty(true);
|
||||||
|
if (enabledRef.current) {
|
||||||
|
window.clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = window.setTimeout(push, PUSH_DELAY_MS);
|
||||||
|
}
|
||||||
|
}, [bookmarks, highlights, push]);
|
||||||
|
|
||||||
|
// Puts `next` on screen and in localStorage without treating it as a local edit.
|
||||||
|
const apply = useCallback((next) => {
|
||||||
|
if (marksEqual(next, latestRef.current)) return;
|
||||||
|
skipRef.current = true;
|
||||||
|
lsSet(KEYS.bookmarks, JSON.stringify(next.bookmarks));
|
||||||
|
lsSet(KEYS.highlights, JSON.stringify(next.highlights));
|
||||||
|
setBookmarks(next.bookmarks);
|
||||||
|
setHighlights(next.highlights);
|
||||||
|
}, [setBookmarks, setHighlights]);
|
||||||
|
|
||||||
|
// Sign-in (and tab focus): reconcile this device with the server's copy.
|
||||||
|
const pullServerMarks = useCallback(async () => {
|
||||||
|
enabledRef.current = true;
|
||||||
|
lastPullRef.current = Date.now();
|
||||||
|
const result = await getRemoteMarks();
|
||||||
|
if (!result.ok) return;
|
||||||
|
const server = normalizeMarks(result.data);
|
||||||
|
const serverAt = Number(result.data?.updatedAt) || 0;
|
||||||
|
const local = normalizeMarks(latestRef.current);
|
||||||
|
const syncedAt = Number(lsGet(KEYS.syncedAt)) || 0;
|
||||||
|
const dirty = lsGet(KEYS.dirty) === '1';
|
||||||
|
|
||||||
|
let next = local;
|
||||||
|
let needsPush = false;
|
||||||
|
if (serverAt === 0) {
|
||||||
|
needsPush = !isEmptyMarks(local); // first time on this account: the server takes what's here
|
||||||
|
} else if (syncedAt === 0) {
|
||||||
|
next = mergeMarks(local, server); // first time this device meets the server's copy: keep both
|
||||||
|
needsPush = !marksEqual(next, server);
|
||||||
|
} else if (serverAt > syncedAt) {
|
||||||
|
next = dirty ? mergeMarks(local, server) : server; // another device saved since we last synced
|
||||||
|
needsPush = dirty && !marksEqual(next, server);
|
||||||
|
} else {
|
||||||
|
needsPush = dirty; // server has nothing newer than what we already have
|
||||||
|
}
|
||||||
|
|
||||||
|
apply(next);
|
||||||
|
if (serverAt > 0) lsSet(KEYS.syncedAt, String(serverAt));
|
||||||
|
if (needsPush) { setDirty(true); latestRef.current = next; await push(); } else if (serverAt >= syncedAt) setDirty(false);
|
||||||
|
}, [apply, push]);
|
||||||
|
|
||||||
|
// Save anything pending before signing out.
|
||||||
|
const flushMarks = useCallback(async () => {
|
||||||
|
if (enabledRef.current && lsGet(KEYS.dirty) === '1') await push();
|
||||||
|
}, [push]);
|
||||||
|
|
||||||
|
// Signed out: stop syncing and clear this device's copy, so the next person to sign in
|
||||||
|
// here starts clean (their marks come from their own account).
|
||||||
|
const stopMarksSync = useCallback(() => {
|
||||||
|
enabledRef.current = false;
|
||||||
|
window.clearTimeout(timerRef.current);
|
||||||
|
[KEYS.bookmarks, KEYS.highlights, KEYS.syncedAt, KEYS.dirty].forEach(lsDel);
|
||||||
|
apply(emptyMarks());
|
||||||
|
}, [apply]);
|
||||||
|
|
||||||
|
// Coming back to the tab (another device may have saved), or leaving it (save now).
|
||||||
|
useEffect(() => {
|
||||||
|
const onVisibility = () => {
|
||||||
|
if (!enabledRef.current) return;
|
||||||
|
if (document.visibilityState === 'hidden') { if (lsGet(KEYS.dirty) === '1') push(); return; }
|
||||||
|
if (Date.now() - lastPullRef.current > FOCUS_PULL_MIN_MS) pullServerMarks();
|
||||||
|
};
|
||||||
|
document.addEventListener('visibilitychange', onVisibility);
|
||||||
|
return () => document.removeEventListener('visibilitychange', onVisibility);
|
||||||
|
}, [push, pullServerMarks]);
|
||||||
|
|
||||||
|
useEffect(() => () => window.clearTimeout(timerRef.current), []);
|
||||||
|
|
||||||
|
return { pullServerMarks, flushMarks, stopMarksSync };
|
||||||
|
}
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
// A simple "read this book in N weeks" plan, stored per browser.
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { CHAPTER_COUNTS } from '../utils/bibleUtils.jsx';
|
|
||||||
|
|
||||||
export function useReadingPlan() {
|
|
||||||
const [readingPlan, setReadingPlan] = useState(() => {
|
|
||||||
try { return JSON.parse(localStorage.getItem('readingPlan') || 'null'); } catch { return null; }
|
|
||||||
});
|
|
||||||
|
|
||||||
const createReadingPlan = (bookAbbrev, bookName, weeks) => {
|
|
||||||
const totalChapters = CHAPTER_COUNTS[bookAbbrev] ?? 1;
|
|
||||||
const plan = { bookAbbrev, bookName, totalChapters, startDate: Date.now(), targetDate: Date.now() + weeks * 7 * 24 * 60 * 60 * 1000, chaptersRead: [] };
|
|
||||||
setReadingPlan(plan);
|
|
||||||
try { localStorage.setItem('readingPlan', JSON.stringify(plan)); } catch {}
|
|
||||||
};
|
|
||||||
|
|
||||||
const markChapterRead = (chapter) => {
|
|
||||||
setReadingPlan((prev) => {
|
|
||||||
if (!prev) return prev;
|
|
||||||
const chaptersRead = prev.chaptersRead.includes(chapter)
|
|
||||||
? prev.chaptersRead.filter((c) => c !== chapter)
|
|
||||||
: [...prev.chaptersRead, chapter].sort((a, b) => a - b);
|
|
||||||
const next = { ...prev, chaptersRead };
|
|
||||||
try { localStorage.setItem('readingPlan', JSON.stringify(next)); } catch {}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearReadingPlan = () => {
|
|
||||||
setReadingPlan(null);
|
|
||||||
try { localStorage.removeItem('readingPlan'); } catch {}
|
|
||||||
};
|
|
||||||
|
|
||||||
return { readingPlan, createReadingPlan, markChapterRead, clearReadingPlan };
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
// Tracks which reading plans (see utils/readingPlans.js) a user has started,
|
||||||
|
// which days they've checked off, which plans they've finished, any plans
|
||||||
|
// they've created themselves, and a cross-plan activity log (for streaks).
|
||||||
|
// Saved locally right away; synced to the server via an effect that watches
|
||||||
|
// `state` (see below) so sync success/failure can actually be tracked and
|
||||||
|
// shown, instead of being a fire-and-forget call buried inside setState.
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { getRemoteReadingProgress, saveRemoteReadingProgress } from '../syncService.js';
|
||||||
|
import { useToast } from '../components/Toasts.jsx';
|
||||||
|
import { mergeReadingPlans } from '../utils/backup.js';
|
||||||
|
import { clampPlanDays, localDateString, normalizeCustomPlan, registerCustomPlans } from '../utils/readingPlans.js';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'readingPlanProgress';
|
||||||
|
|
||||||
|
function loadLocal() {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null');
|
||||||
|
if (Array.isArray(raw)) return { progress: raw, customPlans: [], activity: [] }; // pre-custom-plans shape
|
||||||
|
return {
|
||||||
|
progress: raw?.progress ?? [],
|
||||||
|
customPlans: raw?.customPlans ?? [],
|
||||||
|
activity: raw?.activity ?? [],
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { progress: [], customPlans: [], activity: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeId = () => (crypto.randomUUID?.() ?? `${Date.now()}-${Math.random()}`);
|
||||||
|
|
||||||
|
function formatSyncError(result) {
|
||||||
|
const message = result.error || 'Network error';
|
||||||
|
if (!result.status || message.startsWith('HTTP')) return message;
|
||||||
|
return `HTTP ${result.status}: ${message}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReadingPlans() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [state, setState] = useState(loadLocal);
|
||||||
|
const stateRef = useRef(state); // the latest state, for taking a copy just before something is removed
|
||||||
|
stateRef.current = state;
|
||||||
|
const [syncStatus, setSyncStatus] = useState(''); // '' | 'syncing' | 'synced' | 'error'
|
||||||
|
const [syncError, setSyncError] = useState('');
|
||||||
|
// The exact object loadLocal() produced at mount. Compared by reference (not
|
||||||
|
// a boolean "first render" flag) so React 18 StrictMode's dev-only double
|
||||||
|
// effect invocation can't defeat the skip-on-load guard below — both
|
||||||
|
// invocations see the same unchanged `state` reference either way.
|
||||||
|
const initialStateRef = useRef(state);
|
||||||
|
const skipNextSyncRef = useRef(false); // set right before a server merge, so we don't PUT straight back what we just GOT
|
||||||
|
|
||||||
|
// Keeps the readingPlans.js lookup (getPlan/getPlanSchedule) in sync with
|
||||||
|
// this user's custom plans before any consumer renders this commit.
|
||||||
|
registerCustomPlans(state.customPlans);
|
||||||
|
// What the rest of the app sees: plans with more days than chapters are clamped.
|
||||||
|
const customPlans = useMemo(() => state.customPlans.map(normalizeCustomPlan), [state.customPlans]);
|
||||||
|
|
||||||
|
// Persist locally + sync to the server whenever state changes. Runs as a
|
||||||
|
// real effect (not inside the setState updater) so the network call has a
|
||||||
|
// trustworthy `state` value and its result can update sync status safely.
|
||||||
|
useEffect(() => {
|
||||||
|
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch {}
|
||||||
|
|
||||||
|
if (state === initialStateRef.current) {
|
||||||
|
return; // nothing's actually changed since load — don't sync just because we mounted
|
||||||
|
}
|
||||||
|
if (skipNextSyncRef.current) {
|
||||||
|
skipNextSyncRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setSyncStatus('syncing');
|
||||||
|
saveRemoteReadingProgress(state).then((result) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (result.ok) {
|
||||||
|
setSyncStatus('synced');
|
||||||
|
setSyncError('');
|
||||||
|
} else {
|
||||||
|
setSyncStatus('error');
|
||||||
|
setSyncError(formatSyncError(result));
|
||||||
|
console.error('Reading plan sync failed:', result.status, result.error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
const updateProgress = useCallback((updater) => {
|
||||||
|
setState((prev) => ({ ...prev, progress: updater(prev.progress) }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateCustomPlans = useCallback((updater) => {
|
||||||
|
setState((prev) => ({ ...prev, customPlans: updater(prev.customPlans) }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startPlan = useCallback((planId, translation) => {
|
||||||
|
updateProgress((prev) => {
|
||||||
|
if (prev.some((p) => p.planId === planId && !p.completedAt)) return prev;
|
||||||
|
const record = {
|
||||||
|
id: makeId(),
|
||||||
|
planId,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
completedDays: [],
|
||||||
|
completedAt: null,
|
||||||
|
lastActivityAt: Date.now(),
|
||||||
|
translation: translation || undefined, // which Bible this plan is read in (undefined = the reader's default)
|
||||||
|
chaptersRead: {}, // dayIndex -> ['JHN.8', ...] chapters finished within that day
|
||||||
|
};
|
||||||
|
return [record, ...prev];
|
||||||
|
});
|
||||||
|
}, [updateProgress]);
|
||||||
|
|
||||||
|
// Applies a change to one plan's progress record and keeps the cross-plan
|
||||||
|
// activity log in step: finishing any day (for the first time today) extends the streak.
|
||||||
|
const patchProgress = useCallback((progressId, patcher, totalDays) => {
|
||||||
|
setState((prev) => {
|
||||||
|
const target = prev.progress.find((p) => p.id === progressId);
|
||||||
|
if (!target) return prev;
|
||||||
|
let newlyDone = false;
|
||||||
|
const progress = prev.progress.map((p) => {
|
||||||
|
if (p.id !== progressId) return p;
|
||||||
|
const next = patcher(p);
|
||||||
|
newlyDone = next.completedDays.length > p.completedDays.length;
|
||||||
|
const completedAt = totalDays != null && next.completedDays.length >= totalDays ? (p.completedAt ?? Date.now()) : (totalDays != null ? null : p.completedAt);
|
||||||
|
return { ...next, completedAt, lastActivityAt: Date.now() };
|
||||||
|
});
|
||||||
|
// Only marking a day *done* extends the streak; unchecking one doesn't erase it.
|
||||||
|
const today = localDateString();
|
||||||
|
const activity = newlyDone && !prev.activity.includes(today) ? [...prev.activity, today] : prev.activity;
|
||||||
|
return { ...prev, progress, activity };
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const withoutDay = (chaptersRead, dayIndex) => {
|
||||||
|
const { [dayIndex]: _removed, ...rest } = chaptersRead ?? {};
|
||||||
|
return rest;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Marks a whole day done/undone. Undoing also clears that day's per-chapter ticks.
|
||||||
|
const toggleDay = useCallback((progressId, dayIndex, totalDays) => {
|
||||||
|
patchProgress(progressId, (p) => {
|
||||||
|
const wasAlready = p.completedDays.includes(dayIndex);
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
completedDays: wasAlready
|
||||||
|
? p.completedDays.filter((d) => d !== dayIndex)
|
||||||
|
: [...p.completedDays, dayIndex].sort((a, b) => a - b),
|
||||||
|
chaptersRead: wasAlready ? withoutDay(p.chaptersRead, dayIndex) : p.chaptersRead,
|
||||||
|
};
|
||||||
|
}, totalDays);
|
||||||
|
}, [patchProgress]);
|
||||||
|
|
||||||
|
// Ticks (or unticks) one chapter of a day. `dayKeys` are all of that day's chapter
|
||||||
|
// keys ("JHN.8"); once every one is ticked the day completes by itself, and
|
||||||
|
// unticking a chapter of a finished day reopens it. `force` = true/false sets it
|
||||||
|
// instead of toggling (used by the auto-mark so it can't accidentally untick).
|
||||||
|
const toggleChapterRead = useCallback((progressId, dayIndex, chapterKey, dayKeys, totalDays, force) => {
|
||||||
|
patchProgress(progressId, (p) => {
|
||||||
|
const read = new Set((p.chaptersRead ?? {})[dayIndex] ?? []);
|
||||||
|
const want = force ?? !read.has(chapterKey);
|
||||||
|
if (want) read.add(chapterKey); else read.delete(chapterKey);
|
||||||
|
const allRead = dayKeys.every((k) => read.has(k));
|
||||||
|
const isDone = p.completedDays.includes(dayIndex);
|
||||||
|
let completedDays = p.completedDays;
|
||||||
|
if (allRead && !isDone) completedDays = [...completedDays, dayIndex].sort((a, b) => a - b);
|
||||||
|
if (!allRead && isDone) completedDays = completedDays.filter((d) => d !== dayIndex);
|
||||||
|
return { ...p, completedDays, chaptersRead: { ...(p.chaptersRead ?? {}), [dayIndex]: [...read] } };
|
||||||
|
}, totalDays);
|
||||||
|
}, [patchProgress]);
|
||||||
|
|
||||||
|
// Which Bible a plan is read in (any translation the reader offers).
|
||||||
|
const setPlanTranslation = useCallback((progressId, translation) => {
|
||||||
|
updateProgress((prev) => prev.map((p) => (p.id === progressId ? { ...p, translation } : p)));
|
||||||
|
}, [updateProgress]);
|
||||||
|
|
||||||
|
// Restarting and removing are immediate, with an "Undo" toast that puts the plan back exactly as it was.
|
||||||
|
const restartPlan = useCallback((progressId) => {
|
||||||
|
const before = stateRef.current.progress.find((p) => p.id === progressId);
|
||||||
|
updateProgress((prev) => prev.map((p) => (
|
||||||
|
p.id === progressId
|
||||||
|
? { ...p, completedDays: [], chaptersRead: {}, completedAt: null, startedAt: Date.now(), lastActivityAt: Date.now() }
|
||||||
|
: p
|
||||||
|
)));
|
||||||
|
if (before) toast('Plan restarted from day 1', { action: { label: 'Undo', onClick: () => updateProgress((prev) => prev.map((p) => (p.id === progressId ? before : p))) } });
|
||||||
|
}, [updateProgress, toast]);
|
||||||
|
|
||||||
|
const removePlanProgress = useCallback((progressId) => {
|
||||||
|
const index = stateRef.current.progress.findIndex((p) => p.id === progressId);
|
||||||
|
const before = stateRef.current.progress[index];
|
||||||
|
updateProgress((prev) => prev.filter((p) => p.id !== progressId));
|
||||||
|
if (before) {
|
||||||
|
toast('Plan removed', {
|
||||||
|
action: {
|
||||||
|
label: 'Undo',
|
||||||
|
onClick: () => updateProgress((prev) => (prev.some((p) => p.id === progressId) ? prev : [...prev.slice(0, index), before, ...prev.slice(index)])),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [updateProgress, toast]);
|
||||||
|
|
||||||
|
// Resets the pace clock so a plan that's fallen behind schedule stops being
|
||||||
|
// flagged as "behind" — doesn't touch which days are actually marked done.
|
||||||
|
const recalibratePace = useCallback((progressId) => {
|
||||||
|
updateProgress((prev) => prev.map((p) => (
|
||||||
|
p.id === progressId
|
||||||
|
? { ...p, startedAt: Date.now() - p.completedDays.length * 86400000, lastActivityAt: Date.now() }
|
||||||
|
: p
|
||||||
|
)));
|
||||||
|
}, [updateProgress]);
|
||||||
|
|
||||||
|
// { title, description, days, books: [abbrev, ...], chapterRanges?, image?: dataUrl } -> new custom plan id
|
||||||
|
const addCustomPlan = useCallback(({ title, description, days, books, chapterRanges, image }) => {
|
||||||
|
const id = `custom-${makeId()}`;
|
||||||
|
updateCustomPlans((prev) => [
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
title: title.trim(),
|
||||||
|
description: (description ?? '').trim(),
|
||||||
|
category: 'My Plans',
|
||||||
|
icon: '✨',
|
||||||
|
image: image || undefined,
|
||||||
|
days: clampPlanDays(books, chapterRanges, days),
|
||||||
|
books,
|
||||||
|
chapterRanges: chapterRanges && Object.keys(chapterRanges).length > 0 ? chapterRanges : undefined,
|
||||||
|
custom: true,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
...prev,
|
||||||
|
]);
|
||||||
|
return id;
|
||||||
|
}, [updateCustomPlans]);
|
||||||
|
|
||||||
|
const removeCustomPlan = useCallback((planId) => {
|
||||||
|
const plan = stateRef.current.customPlans.find((p) => p.id === planId);
|
||||||
|
const runs = stateRef.current.progress.filter((p) => p.planId === planId);
|
||||||
|
updateCustomPlans((prev) => prev.filter((p) => p.id !== planId));
|
||||||
|
updateProgress((prev) => prev.filter((p) => p.planId !== planId));
|
||||||
|
if (plan) {
|
||||||
|
toast(`Deleted “${plan.title}”`, {
|
||||||
|
action: {
|
||||||
|
label: 'Undo',
|
||||||
|
onClick: () => {
|
||||||
|
updateCustomPlans((prev) => (prev.some((p) => p.id === planId) ? prev : [plan, ...prev]));
|
||||||
|
updateProgress((prev) => [...runs.filter((r) => !prev.some((p) => p.id === r.id)), ...prev]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [updateCustomPlans, updateProgress, toast]);
|
||||||
|
|
||||||
|
// Patches an existing custom plan. Pass resetProgress: true when the edit
|
||||||
|
// changes its books/days (day indices from before the edit no longer mean
|
||||||
|
// anything), which restarts any in-progress runs of it from day 1.
|
||||||
|
const updateCustomPlan = useCallback((planId, patch, { resetProgress = false } = {}) => {
|
||||||
|
updateCustomPlans((prev) => prev.map((p) => (p.id === planId ? { ...p, ...patch } : p)));
|
||||||
|
if (resetProgress) {
|
||||||
|
updateProgress((prev) => prev.map((p) => (
|
||||||
|
p.planId === planId
|
||||||
|
? { ...p, completedDays: [], chaptersRead: {}, completedAt: null, startedAt: Date.now(), lastActivityAt: Date.now() }
|
||||||
|
: p
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}, [updateCustomPlans, updateProgress]);
|
||||||
|
|
||||||
|
// Plans from a backup file are added to what is here (nothing is removed).
|
||||||
|
const importReadingPlans = useCallback((incoming) => {
|
||||||
|
setState((prev) => ({ ...prev, ...mergeReadingPlans(prev, incoming) }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Server wins over whatever's local at sign-in.
|
||||||
|
const mergeServerReadingProgress = useCallback((serverData) => {
|
||||||
|
if (!serverData || typeof serverData !== 'object' || Array.isArray(serverData)) {
|
||||||
|
console.warn('Reading plan pull: server returned unexpected data', serverData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = {
|
||||||
|
progress: Array.isArray(serverData.progress) ? serverData.progress : [],
|
||||||
|
customPlans: Array.isArray(serverData.customPlans) ? serverData.customPlans : [],
|
||||||
|
activity: Array.isArray(serverData.activity) ? serverData.activity : [],
|
||||||
|
};
|
||||||
|
// Temporary diagnostic — remove once cross-device sync is confirmed fixed.
|
||||||
|
console.log(
|
||||||
|
`Reading plans pulled from server: ${next.progress.length} in progress, ${next.customPlans.length} custom plan(s).`,
|
||||||
|
next,
|
||||||
|
);
|
||||||
|
skipNextSyncRef.current = true;
|
||||||
|
setState(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetches the server's copy at sign-in. Unlike the old inline version of
|
||||||
|
// this call, a failed GET is now visible (same error banner as a failed
|
||||||
|
// push) instead of silently leaving you on stale/empty local state — which
|
||||||
|
// looks identical to "you have no reading plans" with zero indication why.
|
||||||
|
const pullServerReadingProgress = useCallback(async () => {
|
||||||
|
const result = await getRemoteReadingProgress();
|
||||||
|
if (result.ok) {
|
||||||
|
mergeServerReadingProgress(result.data);
|
||||||
|
} else {
|
||||||
|
setSyncStatus('error');
|
||||||
|
setSyncError(formatSyncError(result));
|
||||||
|
console.error('Reading plan pull failed:', result.status, result.error);
|
||||||
|
}
|
||||||
|
}, [mergeServerReadingProgress]);
|
||||||
|
|
||||||
|
// Manual retry after a failed sync (surfaced next to the sync error).
|
||||||
|
const retryReadingPlanSync = useCallback(() => {
|
||||||
|
setSyncStatus('syncing');
|
||||||
|
saveRemoteReadingProgress(state).then((result) => {
|
||||||
|
if (result.ok) {
|
||||||
|
setSyncStatus('synced');
|
||||||
|
setSyncError('');
|
||||||
|
} else {
|
||||||
|
setSyncStatus('error');
|
||||||
|
setSyncError(formatSyncError(result));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
readingPlansState: state,
|
||||||
|
importReadingPlans,
|
||||||
|
planProgress: state.progress,
|
||||||
|
customPlans,
|
||||||
|
readingActivity: state.activity,
|
||||||
|
readingPlanSyncStatus: syncStatus,
|
||||||
|
readingPlanSyncError: syncError,
|
||||||
|
retryReadingPlanSync,
|
||||||
|
startPlan,
|
||||||
|
toggleDay,
|
||||||
|
toggleChapterRead,
|
||||||
|
setPlanTranslation,
|
||||||
|
restartPlan,
|
||||||
|
removePlanProgress,
|
||||||
|
recalibratePace,
|
||||||
|
addCustomPlan,
|
||||||
|
removeCustomPlan,
|
||||||
|
updateCustomPlan,
|
||||||
|
mergeServerReadingProgress,
|
||||||
|
pullServerReadingProgress,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { renderHook, act } from '@testing-library/react';
|
||||||
|
import { vi, test, expect, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../syncService.js', () => ({
|
||||||
|
getRemoteReadingProgress: vi.fn(async () => ({ ok: true, data: {} })),
|
||||||
|
saveRemoteReadingProgress: vi.fn(async () => ({ ok: true })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useReadingPlans } from './useReadingPlans.js';
|
||||||
|
|
||||||
|
beforeEach(() => { localStorage.clear(); });
|
||||||
|
|
||||||
|
const start = (translation) => {
|
||||||
|
const hook = renderHook(() => useReadingPlans());
|
||||||
|
act(() => hook.result.current.startPlan('gospels-90', translation));
|
||||||
|
return hook;
|
||||||
|
};
|
||||||
|
const rec = (hook) => hook.result.current.planProgress[0];
|
||||||
|
|
||||||
|
test('a started plan remembers its translation', () => {
|
||||||
|
const hook = start('ESV');
|
||||||
|
expect(rec(hook).translation).toBe('ESV');
|
||||||
|
act(() => hook.result.current.setPlanTranslation(rec(hook).id, 'NLT'));
|
||||||
|
expect(rec(hook).translation).toBe('NLT');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ticking every chapter of a day completes it, and unticking reopens it', () => {
|
||||||
|
const hook = start('BSB');
|
||||||
|
const id = rec(hook).id;
|
||||||
|
const keys = ['JHN.8', 'JHN.9'];
|
||||||
|
act(() => hook.result.current.toggleChapterRead(id, 11, 'JHN.8', keys, 90));
|
||||||
|
expect(rec(hook).completedDays).toEqual([]);
|
||||||
|
expect(rec(hook).chaptersRead[11]).toEqual(['JHN.8']);
|
||||||
|
act(() => hook.result.current.toggleChapterRead(id, 11, 'JHN.9', keys, 90));
|
||||||
|
expect(rec(hook).completedDays).toEqual([11]);
|
||||||
|
expect(hook.result.current.readingActivity.length).toBe(1); // streak extended
|
||||||
|
act(() => hook.result.current.toggleChapterRead(id, 11, 'JHN.9', keys, 90));
|
||||||
|
expect(rec(hook).completedDays).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('force=true never unticks a chapter that is already read', () => {
|
||||||
|
const hook = start('BSB');
|
||||||
|
const id = rec(hook).id;
|
||||||
|
act(() => hook.result.current.toggleChapterRead(id, 0, 'JHN.1', ['JHN.1', 'JHN.2'], 90, true));
|
||||||
|
act(() => hook.result.current.toggleChapterRead(id, 0, 'JHN.1', ['JHN.1', 'JHN.2'], 90, true));
|
||||||
|
expect(rec(hook).chaptersRead[0]).toEqual(['JHN.1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unmarking a whole day clears its chapter ticks', () => {
|
||||||
|
const hook = start('BSB');
|
||||||
|
const id = rec(hook).id;
|
||||||
|
act(() => hook.result.current.toggleDay(id, 3, 90));
|
||||||
|
expect(rec(hook).completedDays).toEqual([3]);
|
||||||
|
act(() => hook.result.current.toggleChapterRead(id, 3, 'JHN.4', ['JHN.4'], 90, true));
|
||||||
|
act(() => hook.result.current.toggleDay(id, 3, 90));
|
||||||
|
expect(rec(hook).completedDays).toEqual([]);
|
||||||
|
expect(rec(hook).chaptersRead[3]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finishing the last day completes the plan', () => {
|
||||||
|
const hook = start('BSB');
|
||||||
|
const id = rec(hook).id;
|
||||||
|
act(() => { for (let d = 0; d < 90; d += 1) hook.result.current.toggleDay(id, d, 90); });
|
||||||
|
expect(rec(hook).completedAt).toBeTruthy();
|
||||||
|
act(() => hook.result.current.toggleDay(id, 5, 90));
|
||||||
|
expect(rec(hook).completedAt).toBeNull();
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// A drag-to-resize width for a side panel (the Study stage's right column). The handle owns
|
||||||
|
// pointer dragging, arrow keys and double-click reset; the width is remembered per device.
|
||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
const STEP = 24;
|
||||||
|
const BIG_STEP = 96;
|
||||||
|
|
||||||
|
// containerRef: the element the panel sits at the right edge of (for measuring and clamping).
|
||||||
|
export function useResizableWidth({ storageKey, min = 320, maxAbsolute = 760, fallback = 448, reserve = 600, containerRef }) {
|
||||||
|
const maxFor = () => {
|
||||||
|
const w = containerRef.current?.getBoundingClientRect().width ?? 0;
|
||||||
|
return w > 0 ? Math.max(min, Math.min(maxAbsolute, w - reserve)) : maxAbsolute;
|
||||||
|
};
|
||||||
|
const clamp = (n) => Math.round(Math.min(maxFor(), Math.max(min, n)));
|
||||||
|
|
||||||
|
const [width, setWidthState] = useState(() => {
|
||||||
|
try {
|
||||||
|
const n = Number(localStorage.getItem(storageKey));
|
||||||
|
return Number.isFinite(n) && n > 0 ? Math.min(maxAbsolute, Math.max(min, Math.round(n))) : null;
|
||||||
|
} catch { return null; }
|
||||||
|
});
|
||||||
|
const dragRef = useRef(null);
|
||||||
|
|
||||||
|
const commit = useCallback((n) => {
|
||||||
|
setWidthState(n);
|
||||||
|
try {
|
||||||
|
if (n == null) localStorage.removeItem(storageKey);
|
||||||
|
else localStorage.setItem(storageKey, String(n));
|
||||||
|
} catch { /* storage unavailable */ }
|
||||||
|
}, [storageKey]);
|
||||||
|
|
||||||
|
// Width of the panel right now (state, else measured from the panel element, else a default).
|
||||||
|
const current = (panelEl) => width ?? (panelEl?.getBoundingClientRect().width || fallback);
|
||||||
|
|
||||||
|
const handleProps = (panelRef) => ({
|
||||||
|
role: 'separator',
|
||||||
|
'aria-orientation': 'vertical',
|
||||||
|
'aria-label': 'Resize side panel',
|
||||||
|
'aria-valuemin': min,
|
||||||
|
'aria-valuemax': maxAbsolute,
|
||||||
|
'aria-valuenow': Math.round(current(panelRef.current)),
|
||||||
|
tabIndex: 0,
|
||||||
|
onPointerDown: (e) => {
|
||||||
|
if (e.button !== undefined && e.button !== 0) return;
|
||||||
|
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||||
|
dragRef.current = { id: e.pointerId };
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
},
|
||||||
|
onPointerMove: (e) => {
|
||||||
|
if (!dragRef.current) return;
|
||||||
|
const right = containerRef.current?.getBoundingClientRect().right ?? window.innerWidth;
|
||||||
|
const next = clamp(right - e.clientX);
|
||||||
|
dragRef.current.width = next;
|
||||||
|
containerRef.current?.style.setProperty('--study-right', `${next}px`); // smooth: no React render per move
|
||||||
|
},
|
||||||
|
onPointerUp: (e) => {
|
||||||
|
if (!dragRef.current) return;
|
||||||
|
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
||||||
|
const done = dragRef.current.width;
|
||||||
|
dragRef.current = null;
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
if (done != null) commit(done);
|
||||||
|
},
|
||||||
|
onKeyDown: (e) => {
|
||||||
|
const step = e.shiftKey ? BIG_STEP : STEP;
|
||||||
|
if (e.key === 'ArrowLeft') { e.preventDefault(); commit(clamp(current(panelRef.current) + step)); }
|
||||||
|
else if (e.key === 'ArrowRight') { e.preventDefault(); commit(clamp(current(panelRef.current) - step)); }
|
||||||
|
else if (e.key === 'Home' || e.key === 'Escape') { e.preventDefault(); containerRef.current?.style.removeProperty('--study-right'); commit(null); }
|
||||||
|
},
|
||||||
|
onDoubleClick: () => { containerRef.current?.style.removeProperty('--study-right'); commit(null); },
|
||||||
|
title: 'Drag to resize · double-click to reset',
|
||||||
|
});
|
||||||
|
|
||||||
|
return { width, handleProps };
|
||||||
|
}
|
||||||