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,6 +63,22 @@ 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');
|
||||||
@@ -81,6 +97,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 +160,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 +168,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 +201,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 +247,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 +290,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 +353,12 @@ export function getProject(id, userId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The server copy's lastEdited for a project owned by userId, or null when there isn't one. */
|
||||||
|
export function getProjectEdited(id, userId) {
|
||||||
|
const row = db.prepare('SELECT last_edited AS lastEdited FROM projects WHERE id = ? AND user_id = ?').get(id, userId);
|
||||||
|
return row ? row.lastEdited : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Insert or replace a project owned by userId.
|
* Insert or replace a project owned by userId.
|
||||||
* Returns the summary, or null if the id already belongs to a different user.
|
* Returns the summary, or null if the id already belongs to a different user.
|
||||||
|
|||||||
@@ -6,13 +6,15 @@ 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,
|
||||||
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 +22,21 @@ 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';
|
||||||
|
|
||||||
|
// 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 +124,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 +156,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 +195,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 +213,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 +230,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 +399,11 @@ app.put('/api/projects/:id', requireAuth, (req, res) => {
|
|||||||
}
|
}
|
||||||
if (body.id.length > 100) return res.status(400).json({ error: 'Invalid project id.' });
|
if (body.id.length > 100) return res.status(400).json({ error: 'Invalid project id.' });
|
||||||
if (body.title.length > 500) return res.status(400).json({ error: 'Project title is too long.' });
|
if (body.title.length > 500) return res.status(400).json({ error: 'Project title is too long.' });
|
||||||
|
// Refuse to overwrite a copy another device saved after the one this device last saw
|
||||||
|
const stored = getProjectEdited(body.id, req.session.userId);
|
||||||
|
if (isSaveConflict({ stored, base: parseBase(req.get('X-Base-Edited')), force: req.query.force === '1' })) {
|
||||||
|
return res.status(409).json({ error: 'This study was changed on another device.', code: 'conflict', serverEdited: stored });
|
||||||
|
}
|
||||||
const saved = upsertProject(body, req.session.userId);
|
const saved = upsertProject(body, req.session.userId);
|
||||||
if (!saved) {
|
if (!saved) {
|
||||||
return res.status(403).json({ error: 'That project belongs to a different account.' });
|
return res.status(403).json({ error: 'That project belongs to a different account.' });
|
||||||
@@ -394,6 +456,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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -545,6 +709,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,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,320 @@
|
|||||||
|
// How the app moves and recovers: the Back button, the ⌘K palette, Undo, sync conflicts, backups.
|
||||||
|
import { render, screen, waitFor, within, fireEvent } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { vi, beforeEach, afterEach, describe, test, expect } from 'vitest';
|
||||||
|
import App from './App';
|
||||||
|
import { resetLexiconCaches } from './utils/wordStudy.js';
|
||||||
|
import { switchStorageUser } from './utils/bibleUtils.jsx';
|
||||||
|
import { resetSyncBases } from './utils/syncBase.js';
|
||||||
|
|
||||||
|
const chapterData = {
|
||||||
|
chapter: { content: [
|
||||||
|
{ type: 'verse', number: 1, content: ['In the beginning God created.'] },
|
||||||
|
{ type: 'verse', number: 2, content: ['The earth was without form.'] },
|
||||||
|
] },
|
||||||
|
};
|
||||||
|
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
|
||||||
|
|
||||||
|
// A fake server: signed in as `user`, a settable answer for saving studies, and a log of every call.
|
||||||
|
function stubServer({ user = null, putStatus = 200 } = {}) {
|
||||||
|
const calls = [];
|
||||||
|
const state = { putStatus };
|
||||||
|
vi.stubGlobal('fetch', vi.fn((url, opts = {}) => {
|
||||||
|
const route = `${opts.method ?? 'GET'} ${url.split('?')[0]}`;
|
||||||
|
calls.push({ route, url, opts });
|
||||||
|
if (url.includes('bible.helloao.org')) return json(200, chapterData);
|
||||||
|
if (!url.startsWith('/api/')) return Promise.resolve({ ok: false });
|
||||||
|
if (route === 'GET /api/auth/me') return user ? json(200, user) : json(401, { error: 'Not signed in' });
|
||||||
|
if (route === 'GET /api/projects') return json(200, []);
|
||||||
|
if (route === 'GET /api/reader/ink') return json(200, {});
|
||||||
|
if (route.startsWith('PUT /api/projects/')) {
|
||||||
|
return state.putStatus === 409
|
||||||
|
? json(409, { error: 'This study was changed on another device.', code: 'conflict', serverEdited: 999 })
|
||||||
|
: json(200, { id: url.split('/').pop().split('?')[0], title: 'x', lastEdited: 12345, chapterSummary: '' });
|
||||||
|
}
|
||||||
|
return json(404, { error: 'not mocked' });
|
||||||
|
}));
|
||||||
|
return { calls, state };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
resetLexiconCaches();
|
||||||
|
resetSyncBases();
|
||||||
|
switchStorageUser(null);
|
||||||
|
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ ok: false })));
|
||||||
|
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
|
||||||
|
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); });
|
||||||
|
|
||||||
|
async function renderApp() {
|
||||||
|
render(<App />);
|
||||||
|
await waitFor(() => expect(screen.queryByText('Loading…')).not.toBeInTheDocument());
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedProject({ id = 'p1', title = 'Titus Notes', observation = 'Grace abounds to all.' } = {}) {
|
||||||
|
const project = {
|
||||||
|
id, title, translation: 'BSB', lastEdited: Date.now(), selectedChunkId: null,
|
||||||
|
chapters: [{
|
||||||
|
book: 'Titus', bookAbbrev: 'TIT', chapter: '1', verses: [{ number: 1, text: 'Paul, a servant of God.' }],
|
||||||
|
chunks: [{ id: 'c1', startVerse: 1, endVerse: 1, observation, interpretation: '', application: '', crossReferences: [], greekWords: [], tags: [] }],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
const index = JSON.parse(localStorage.getItem('bible-study-index') || '[]');
|
||||||
|
index.push({ id, title, lastEdited: project.lastEdited, chapterSummary: 'Titus 1', tags: [] });
|
||||||
|
localStorage.setItem(`bible-study-project-${id}`, JSON.stringify(project));
|
||||||
|
localStorage.setItem('bible-study-index', JSON.stringify(index));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Back button', () => {
|
||||||
|
test('moves back through the pages you visited instead of leaving the app', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
expect(await screen.findByRole('heading', { name: 'My Studies' })).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: /reading plans/i }));
|
||||||
|
expect(await screen.findByRole('heading', { name: /reading plans/i })).toBeInTheDocument();
|
||||||
|
window.history.back();
|
||||||
|
expect(await screen.findByRole('heading', { name: 'My Studies' })).toBeInTheDocument();
|
||||||
|
window.history.forward();
|
||||||
|
expect(await screen.findByRole('heading', { name: /reading plans/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const paletteBox = async () => within(await screen.findByRole('dialog', { name: /search and go to/i })).getByRole('combobox');
|
||||||
|
|
||||||
|
describe('Search palette (⌘K)', () => {
|
||||||
|
test('a typed passage goes straight to the Bible reader', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await user.keyboard('{Control>}k{/Control}');
|
||||||
|
await user.type(await paletteBox(), 'john 3:16');
|
||||||
|
expect(await screen.findByRole('option', { name: /John 3:16/ })).toBeInTheDocument();
|
||||||
|
await user.keyboard('{Enter}');
|
||||||
|
expect(await screen.findByRole('heading', { name: /John 3/ })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('dialog', { name: /search and go to/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finds a note you wrote and opens that study', async () => {
|
||||||
|
seedProject({ observation: 'Grace abounds to all.' });
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await user.keyboard('{Control>}k{/Control}');
|
||||||
|
await user.type(await paletteBox(), 'abounds');
|
||||||
|
const hit = await screen.findByRole('option', { name: /Titus 1:1/ });
|
||||||
|
await user.click(hit);
|
||||||
|
expect(await screen.findByText(/Your notes/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Escape closes it', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await user.keyboard('{Control>}k{/Control}');
|
||||||
|
await paletteBox();
|
||||||
|
await user.keyboard('{Escape}');
|
||||||
|
expect(screen.queryByRole('dialog', { name: /search and go to/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('? lists the keyboard shortcuts', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await user.keyboard('?');
|
||||||
|
expect(await screen.findByRole('dialog', { name: /keyboard shortcuts/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Undo', () => {
|
||||||
|
test('deleting a project, then Undo, keeps its notes', async () => {
|
||||||
|
seedProject();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await user.click(screen.getByRole('button', { name: /^delete$/i }));
|
||||||
|
expect(await screen.findByRole('status')).toHaveTextContent(/deleted/i);
|
||||||
|
await user.click(screen.getByRole('button', { name: /undo/i }));
|
||||||
|
expect(await screen.findByText('Titus Notes')).toBeInTheDocument();
|
||||||
|
expect(JSON.parse(localStorage.getItem('bible-study-project-p1')).chapters[0].chunks[0].observation).toBe('Grace abounds to all.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Sync conflict', () => {
|
||||||
|
const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: '', isAdmin: false };
|
||||||
|
|
||||||
|
async function startStudy(u) {
|
||||||
|
await renderApp();
|
||||||
|
await u.click((await screen.findAllByRole('button', { name: /new project/i }))[0]);
|
||||||
|
await u.click(await screen.findByRole('button', { name: /load chapter/i }));
|
||||||
|
await screen.findByText(/Scripture & Chunks/i);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a save the server refuses shows the choice instead of overwriting', async () => {
|
||||||
|
const server = stubServer({ user, putStatus: 409 });
|
||||||
|
const u = userEvent.setup();
|
||||||
|
await startStudy(u);
|
||||||
|
const banner = await screen.findByRole('alert', {}, { timeout: 4000 });
|
||||||
|
expect(banner).toHaveTextContent(/changed on another device/i);
|
||||||
|
expect(within(banner).getByRole('button', { name: /keep this version/i })).toBeInTheDocument();
|
||||||
|
expect(within(banner).getByRole('button', { name: /use the other version/i })).toBeInTheDocument();
|
||||||
|
|
||||||
|
// "Keep this version" sends the save again, this time as an overwrite
|
||||||
|
server.state.putStatus = 200;
|
||||||
|
await u.click(within(banner).getByRole('button', { name: /keep this version/i }));
|
||||||
|
await waitFor(() => expect(server.calls.some((c) => c.url.includes('?force=1'))).toBe(true));
|
||||||
|
await waitFor(() => expect(screen.queryByText(/changed on another device/i)).not.toBeInTheDocument());
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
test('opening a study without editing it does not touch the server', async () => {
|
||||||
|
seedProject();
|
||||||
|
const server = stubServer({ user });
|
||||||
|
const u = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
// signed in: the namespaced storage starts empty, so seed under the user's keys by resuming nothing
|
||||||
|
expect(server.calls.filter((c) => c.route.startsWith('PUT')).length).toBe(0);
|
||||||
|
await u.click(await screen.findByRole('button', { name: /read bible/i }));
|
||||||
|
expect(server.calls.filter((c) => c.route.startsWith('PUT /api/projects')).length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Outline stage', () => {
|
||||||
|
async function openOutline() {
|
||||||
|
// local-only mode (the account server is unreachable), with chapter text available
|
||||||
|
vi.stubGlobal('fetch', vi.fn((url) => (url.includes('bible.helloao.org') ? json(200, chapterData) : Promise.resolve({ ok: false }))));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await user.click((await screen.findAllByRole('button', { name: /new project/i }))[0]);
|
||||||
|
await user.click(await screen.findByRole('button', { name: /load chapter/i }));
|
||||||
|
await screen.findByText(/Scripture & Chunks/i);
|
||||||
|
// one chunk covering verses 1-2
|
||||||
|
const verses = within(screen.getByTestId('verse-list'));
|
||||||
|
fireEvent.click(verses.getByRole('button', { name: /In the beginning/ }));
|
||||||
|
fireEvent.click(verses.getByRole('button', { name: /without form/ }), { shiftKey: true });
|
||||||
|
await screen.findByText('1 created');
|
||||||
|
await user.click(screen.getByRole('button', { name: /^outline$/i }));
|
||||||
|
await screen.findByRole('heading', { name: 'Outline' });
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('starts empty, and "Start from my chunks" makes a main point for each chunk', async () => {
|
||||||
|
const user = await openOutline();
|
||||||
|
expect(screen.getByText(/no outline yet/i)).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: /start from my chunks/i }));
|
||||||
|
const list = await screen.findByRole('list', { name: 'Outline' });
|
||||||
|
expect(within(list).getAllByRole('textbox')).toHaveLength(1);
|
||||||
|
expect(within(list).getByText(/Open chunk in Study/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Enter adds a point, Tab makes it a sub-point, and they are numbered I. and A.', async () => {
|
||||||
|
const user = await openOutline();
|
||||||
|
await user.click(screen.getByRole('button', { name: /start blank/i }));
|
||||||
|
const first = await screen.findByRole('textbox', { name: 'Point I.' });
|
||||||
|
await user.type(first, 'Creation{Enter}');
|
||||||
|
const second = await screen.findByRole('textbox', { name: 'Point II.' });
|
||||||
|
await user.type(second, 'Light{Tab}');
|
||||||
|
expect(await screen.findByRole('textbox', { name: 'Point A.' })).toHaveValue('Light');
|
||||||
|
expect(screen.getByRole('textbox', { name: 'Point I.' })).toHaveValue('Creation');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleting a point can be undone', async () => {
|
||||||
|
const user = await openOutline();
|
||||||
|
await user.click(screen.getByRole('button', { name: /start blank/i }));
|
||||||
|
await user.type(await screen.findByRole('textbox', { name: 'Point I.' }), 'Keep me');
|
||||||
|
await user.click(screen.getByRole('button', { name: /delete i\./i }));
|
||||||
|
expect(screen.queryByRole('textbox', { name: 'Point I.' })).not.toBeInTheDocument();
|
||||||
|
await user.click(await screen.findByRole('button', { name: /undo/i }));
|
||||||
|
expect(await screen.findByRole('textbox', { name: 'Point I.' })).toHaveValue('Keep me');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Outline: per chunk, details, print and share', () => {
|
||||||
|
async function openOutline() {
|
||||||
|
vi.stubGlobal('fetch', vi.fn((url) => (url.includes('bible.helloao.org') ? json(200, chapterData) : Promise.resolve({ ok: false }))));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await user.click((await screen.findAllByRole('button', { name: /new project/i }))[0]);
|
||||||
|
await user.click(await screen.findByRole('button', { name: /load chapter/i }));
|
||||||
|
await screen.findByText(/Scripture & Chunks/i);
|
||||||
|
const verses = within(screen.getByTestId('verse-list'));
|
||||||
|
fireEvent.click(verses.getByRole('button', { name: /In the beginning/ }));
|
||||||
|
fireEvent.click(verses.getByRole('button', { name: /without form/ }), { shiftKey: true });
|
||||||
|
await screen.findByText('1 created');
|
||||||
|
await user.click(screen.getByRole('button', { name: /^outline$/i }));
|
||||||
|
await screen.findByRole('heading', { name: 'Outline' });
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('each chunk has its own outline, separate from the whole study\'s', async () => {
|
||||||
|
const user = await openOutline();
|
||||||
|
const picker = screen.getByRole('combobox', { name: 'Outline for' });
|
||||||
|
await user.selectOptions(picker, picker.querySelectorAll('option')[1]); // the chunk
|
||||||
|
expect(await screen.findByText(/no outline yet for/i)).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: /start from the verses/i }));
|
||||||
|
const list = await screen.findByRole('list', { name: 'Outline' });
|
||||||
|
expect(within(list).getAllByRole('textbox')).toHaveLength(2); // one point per verse
|
||||||
|
expect(within(list).getByText(/In the beginning God created/)).toBeInTheDocument(); // with its words
|
||||||
|
await user.selectOptions(screen.getByRole('combobox', { name: 'Outline for' }), 'study');
|
||||||
|
expect(await screen.findByText(/^No outline yet$/)).toBeInTheDocument(); // the whole study is untouched
|
||||||
|
expect(screen.getByRole('option', { name: /· 2/ })).toBeInTheDocument(); // the chunk shows its count
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a point can be given a type and minutes, and the total shows', async () => {
|
||||||
|
const user = await openOutline();
|
||||||
|
await user.click(screen.getByRole('button', { name: /start blank/i }));
|
||||||
|
await user.type(await screen.findByRole('textbox', { name: 'Point I.' }), 'The story');
|
||||||
|
await user.click(screen.getByRole('button', { name: /details for i\./i }));
|
||||||
|
await user.selectOptions(screen.getByLabelText('Type'), 'illustration');
|
||||||
|
await user.type(screen.getByLabelText('Minutes'), '5');
|
||||||
|
expect(await screen.findByText('About 5 minutes')).toBeInTheDocument();
|
||||||
|
await user.type(screen.getByRole('textbox', { name: 'Notes for I.' }), 'Tell it slowly');
|
||||||
|
await user.click(screen.getByRole('button', { name: /details for i\./i })); // close: the note shows as a line
|
||||||
|
expect(await screen.findByText('Tell it slowly')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Illustration')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('"Add verse text" puts the passage\'s words into the point', async () => {
|
||||||
|
const user = await openOutline();
|
||||||
|
await user.click(screen.getByRole('button', { name: /start blank/i }));
|
||||||
|
await user.click(await screen.findByRole('button', { name: /details for i\./i }));
|
||||||
|
await user.type(screen.getByLabelText('Passage'), 'Titus 1:1');
|
||||||
|
await user.click(screen.getByRole('button', { name: /add verse text/i }));
|
||||||
|
expect(await screen.findByText(/In the beginning God created/)).toBeInTheDocument();
|
||||||
|
await user.clear(screen.getByLabelText('Passage'));
|
||||||
|
await user.type(screen.getByLabelText('Passage'), 'nonsense');
|
||||||
|
await user.click(screen.getByRole('button', { name: /replace verse text/i }));
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent(/type a passage/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Share copies the outline when there is no share sheet; Print opens a page of it', async () => {
|
||||||
|
const user = await openOutline();
|
||||||
|
await user.click(screen.getByRole('button', { name: /start from my chunks/i }));
|
||||||
|
await screen.findByRole('list', { name: 'Outline' });
|
||||||
|
await user.click(screen.getByRole('button', { name: /^share$/i }));
|
||||||
|
expect(await screen.findByText(/outline copied/i)).toBeInTheDocument();
|
||||||
|
const doc = { write: vi.fn(), close: vi.fn() };
|
||||||
|
vi.spyOn(window, 'open').mockReturnValue({ document: doc, focus: vi.fn(), print: vi.fn() });
|
||||||
|
await user.click(screen.getByRole('button', { name: /print \/ pdf/i }));
|
||||||
|
expect(doc.write.mock.calls[0][0]).toContain('class="outline"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Study cards on Home', () => {
|
||||||
|
test('a long run of chapters and many tags stay short', async () => {
|
||||||
|
const chapters = Array.from({ length: 24 }, (_, i) => `Luke ${i + 1}`).join(', ');
|
||||||
|
const tags = ['A', 'B', 'C', 'D', 'E', 'F'];
|
||||||
|
localStorage.setItem('bible-study-index', JSON.stringify([{ id: 'p', title: 'Luke', lastEdited: Date.now(), chapterSummary: chapters, tags }]));
|
||||||
|
await renderApp();
|
||||||
|
expect(await screen.findByText('Luke 1\u201324')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('+3 more')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('F', { selector: 'span' })).not.toBeInTheDocument(); // only in the Tag filter
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Window title', () => {
|
||||||
|
test('names the page you are on, including the chapter in the reader', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
expect(document.title).toBe('My Studies · Bible Study');
|
||||||
|
await user.click(screen.getByRole('button', { name: /reading plans/i }));
|
||||||
|
await waitFor(() => expect(document.title).toBe('Reading Plans · Bible Study'));
|
||||||
|
await user.click(screen.getByRole('button', { name: /read bible/i }));
|
||||||
|
await waitFor(() => expect(document.title).toMatch(/^[A-Za-z0-9 ]+ \d+ · Bible Study$/));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,15 +1,25 @@
|
|||||||
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 { 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 +30,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 +86,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 +110,49 @@ const App = () => {
|
|||||||
setCurrentPage,
|
setCurrentPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ⌘K search/go-to, the "?" shortcut list, and requests other pages make of the Reader.
|
||||||
|
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||||
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
|
const [readerRequest, setReaderRequest] = useState(null); // { search: 'text' } from the palette
|
||||||
|
const [settingsFocus, setSettingsFocus] = useState(null); // section Settings should scroll to
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && !e.altKey && e.key.toLowerCase() === 'k') { e.preventDefault(); setHelpOpen(false); setPaletteOpen((o) => !o); return; }
|
||||||
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
|
const t = e.target;
|
||||||
|
if (t?.tagName === 'INPUT' || t?.tagName === 'TEXTAREA' || t?.tagName === 'SELECT' || t?.isContentEditable) return;
|
||||||
|
if (e.key === '?') { e.preventDefault(); setPaletteOpen(false); setHelpOpen((o) => !o); }
|
||||||
|
else if (e.key === '/' && currentPageRef.current !== 'reader') { e.preventDefault(); setPaletteOpen(true); }
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// The browser tab and history show where you are ("Romans 8 · Bible Study", "Luke — Outline · Bible Study")
|
||||||
|
useEffect(() => {
|
||||||
|
const names = {
|
||||||
|
home: 'My Studies', setup: 'Chunks', study: 'Study', outline: 'Outline', script: 'Script', export: 'Export',
|
||||||
|
import: 'Import', pronunciation: 'Pronunciation Guide', settings: 'Settings', admin: 'Admin', 'reading-plans': 'Reading Plans',
|
||||||
|
};
|
||||||
|
let page = names[currentPage] ?? '';
|
||||||
|
if (currentPage === 'reader') {
|
||||||
|
const book = bookOptions.find((b) => b.abbrev === reader.readerBookAbbrev)?.name ?? '';
|
||||||
|
page = `${book} ${reader.readerChapter}`.trim();
|
||||||
|
} else if (project?.title && ['setup', 'study', 'outline', 'script', 'export', 'pronunciation'].includes(currentPage)) {
|
||||||
|
page = `${project.title} — ${page}`;
|
||||||
|
}
|
||||||
|
document.title = page ? `${page} · Bible Study` : 'Bible Study';
|
||||||
|
}, [currentPage, project?.title, reader.readerBookAbbrev, reader.readerChapter]);
|
||||||
|
|
||||||
|
// Back/Forward move between pages instead of leaving the app. (Not for ?share= links: that page is separate.)
|
||||||
|
useHistoryNav({
|
||||||
|
page: currentPage,
|
||||||
|
setPage: setCurrentPage,
|
||||||
|
hasProject: !!project,
|
||||||
|
enabled: !new URLSearchParams(window.location.search).has('share'),
|
||||||
|
onArrive: (page) => { if (page === 'home') setProjectIndex(loadProjectIndex()); },
|
||||||
|
});
|
||||||
|
|
||||||
// Startup: migrate old keys and load index
|
// Startup: migrate old keys and load index
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
migrateOldStorageKeys();
|
migrateOldStorageKeys();
|
||||||
@@ -68,10 +162,45 @@ const App = () => {
|
|||||||
const allChunks = project ? project.chapters.flatMap((ch) => ch.chunks) : [];
|
const allChunks = project ? project.chapters.flatMap((ch) => ch.chunks) : [];
|
||||||
|
|
||||||
// Autosave: 1s after the last edit, save locally, then sync to the server.
|
// Autosave: 1s after the last edit, save locally, then sync to the server.
|
||||||
|
// Opening a study doesn't count as editing it — saving then would stamp the server's copy as "newer"
|
||||||
|
// and make your other devices think it changed.
|
||||||
|
const savedSignatureRef = useRef({ id: null, signature: '' });
|
||||||
|
const [syncConflict, setSyncConflict] = useState(null); // { id, serverEdited } when the server has a newer copy
|
||||||
|
const conflictRef = useRef(null);
|
||||||
|
conflictRef.current = syncConflict;
|
||||||
|
const signatureOf = (p) => JSON.stringify({ ...p, lastEdited: 0 });
|
||||||
|
|
||||||
|
// Sends a study to the server, checked against the copy this device last saw.
|
||||||
|
const pushProject = async (toSave, { force = false } = {}) => {
|
||||||
|
setSyncStatus('syncing');
|
||||||
|
const result = await saveRemoteProject(toSave, { baseEdited: getSyncBase(toSave.id), force });
|
||||||
|
if (result.ok) {
|
||||||
|
setSyncBase(toSave.id, result.data?.lastEdited ?? toSave.lastEdited);
|
||||||
|
setSyncConflict((c) => (c?.id === toSave.id ? null : c));
|
||||||
|
setSyncStatus('synced');
|
||||||
|
window.setTimeout(() => setSyncStatus(''), 2500);
|
||||||
|
} else if (result.status === 409) {
|
||||||
|
setSyncConflict({ id: toSave.id, serverEdited: result.data?.serverEdited ?? null });
|
||||||
|
setSyncStatus('conflict');
|
||||||
|
} else {
|
||||||
|
setSyncStatus('error'); // kept visible until the next successful sync
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!project) return;
|
if (!project) return undefined;
|
||||||
|
const signature = signatureOf(project);
|
||||||
|
const last = savedSignatureRef.current;
|
||||||
|
if (last.id !== project.id) {
|
||||||
|
savedSignatureRef.current = { id: project.id, signature };
|
||||||
|
if (loadProjectById(project.id)) return undefined; // just opened an existing study: nothing to save yet
|
||||||
|
} else if (last.signature === signature) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
||||||
saveTimerRef.current = window.setTimeout(async () => {
|
saveTimerRef.current = window.setTimeout(async () => {
|
||||||
|
savedSignatureRef.current = { id: project.id, signature };
|
||||||
// Stamp lastEdited once so localStorage and server receive the same value
|
// Stamp lastEdited once so localStorage and server receive the same value
|
||||||
const toSave = { ...project, lastEdited: Date.now() };
|
const toSave = { ...project, lastEdited: Date.now() };
|
||||||
|
|
||||||
@@ -81,17 +210,34 @@ const App = () => {
|
|||||||
setSaveStatus('Saved');
|
setSaveStatus('Saved');
|
||||||
window.setTimeout(() => setSaveStatus(''), 1400);
|
window.setTimeout(() => setSaveStatus(''), 1400);
|
||||||
|
|
||||||
// 2. Sync to server with the same timestamped object
|
// 2. Sync to server with the same timestamped object (not while a conflict is waiting on a choice)
|
||||||
setSyncStatus('syncing');
|
if (conflictRef.current?.id === toSave.id) return;
|
||||||
const result = await saveRemoteProject(toSave);
|
await pushProject(toSave);
|
||||||
setSyncStatus(result.ok ? 'synced' : 'error');
|
|
||||||
// On success clear after 2.5s; on error keep visible until next successful sync
|
|
||||||
if (result.ok) window.setTimeout(() => setSyncStatus(''), 2500);
|
|
||||||
}, 1000);
|
}, 1000);
|
||||||
return () => {
|
return () => {
|
||||||
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
||||||
};
|
};
|
||||||
}, [project]);
|
}, [project]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// The two ways out of a conflict: keep this device's version, or take the server's.
|
||||||
|
const keepMyVersion = async () => {
|
||||||
|
if (!project) return;
|
||||||
|
await pushProject({ ...project, lastEdited: Date.now() }, { force: true });
|
||||||
|
};
|
||||||
|
const useServerVersion = async () => {
|
||||||
|
if (!syncConflict) return;
|
||||||
|
const result = await loadRemoteProject(syncConflict.id);
|
||||||
|
if (!result.ok) { toast("Couldn't load the other version. Check your connection and try again.", { tone: 'error' }); return; }
|
||||||
|
saveProjectToStorage(result.data);
|
||||||
|
setSyncBase(syncConflict.id, result.data.lastEdited);
|
||||||
|
const loaded = loadProjectById(syncConflict.id);
|
||||||
|
savedSignatureRef.current = { id: syncConflict.id, signature: signatureOf(loaded) };
|
||||||
|
setProject(loaded);
|
||||||
|
setProjectIndex(loadProjectIndex());
|
||||||
|
setSyncConflict(null);
|
||||||
|
setSyncStatus('');
|
||||||
|
toast('Switched to the version from your other device');
|
||||||
|
};
|
||||||
|
|
||||||
const updateProject = (updater) => {
|
const updateProject = (updater) => {
|
||||||
setProject((current) => (current ? updater(current) : current));
|
setProject((current) => (current ? updater(current) : current));
|
||||||
@@ -110,6 +256,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 +351,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 +382,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 +434,26 @@ const App = () => {
|
|||||||
<Suspense fallback={<PageLoading />}>
|
<Suspense fallback={<PageLoading />}>
|
||||||
<AppRouter />
|
<AppRouter />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
{paletteOpen && <CommandPalette onClose={() => setPaletteOpen(false)} />}
|
||||||
|
{helpOpen && <ShortcutsHelp onClose={() => setHelpOpen(false)} />}
|
||||||
</AppContext.Provider>
|
</AppContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Shown while a page's code downloads: the app bar's outline and a few resting blocks in the
|
||||||
|
// reading theme, so the screen doesn't flash a different colour before the page appears.
|
||||||
function PageLoading() {
|
function PageLoading() {
|
||||||
|
const { readerTheme } = useApp();
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -195,7 +465,7 @@ function AppRouter() {
|
|||||||
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 +475,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 +490,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,63 @@
|
|||||||
|
// The "?" cheat sheet: every keyboard shortcut for the page you are on, plus the ones that work everywhere.
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useApp } from '../context/AppContext.js';
|
||||||
|
import { themeVars } from './reader/readerThemes.js';
|
||||||
|
|
||||||
|
const GLOBAL = [
|
||||||
|
['⌘K / Ctrl K', 'Search and go to a passage'],
|
||||||
|
['?', 'Show this list'],
|
||||||
|
['Esc', 'Close whatever is open'],
|
||||||
|
];
|
||||||
|
const BY_PAGE = {
|
||||||
|
reader: [
|
||||||
|
['← →', 'Previous / next chapter'],
|
||||||
|
['/', 'Search this chapter or the whole Bible'],
|
||||||
|
['G', 'Pick a book and chapter'],
|
||||||
|
['B', 'Bookmark the selected verse'],
|
||||||
|
[',', 'Reading settings'],
|
||||||
|
['F', 'Hide or show the controls'],
|
||||||
|
],
|
||||||
|
study: [
|
||||||
|
['← →', 'Previous / next chunk'],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ShortcutsHelp({ onClose }) {
|
||||||
|
const { readerTheme, currentPage } = useApp();
|
||||||
|
const closeRef = useRef(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const from = document.activeElement;
|
||||||
|
closeRef.current?.focus();
|
||||||
|
const onKey = (e) => { if (e.key === 'Escape' || e.key === '?') { e.preventDefault(); e.stopPropagation(); onClose(); } };
|
||||||
|
document.addEventListener('keydown', onKey, true);
|
||||||
|
return () => { document.removeEventListener('keydown', onKey, true); if (from instanceof HTMLElement && document.contains(from)) from.focus({ preventScroll: true }); };
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const page = BY_PAGE[currentPage] ?? [];
|
||||||
|
const section = (title, rows) => (
|
||||||
|
<div className="mb-4 last:mb-0">
|
||||||
|
<h3 className="mb-1.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">{title}</h3>
|
||||||
|
<dl className="space-y-1.5">
|
||||||
|
{rows.map(([keys, what]) => (
|
||||||
|
<div key={keys} className="flex items-center justify-between gap-4 text-sm">
|
||||||
|
<dt><kbd className="rounded-md border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-0.5 font-mono text-xs">{keys}</kbd></dt>
|
||||||
|
<dd className="text-right text-[var(--r-sub)]">{what}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={themeVars(readerTheme)} className="fixed inset-0 z-[90] flex items-center justify-center bg-black/40 px-3 font-sans" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||||
|
<div role="dialog" aria-modal="true" aria-label="Keyboard shortcuts" className="max-h-[80vh] w-full max-w-sm overflow-y-auto rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5 text-[var(--r-text)] shadow-2xl">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h2 className="text-base font-medium">Keyboard shortcuts</h2>
|
||||||
|
<button ref={closeRef} type="button" onClick={onClose} className="rounded-lg px-2 py-1 text-sm text-[var(--r-sub)] hover:bg-black/5">Close</button>
|
||||||
|
</div>
|
||||||
|
{page.length > 0 && section(currentPage === 'reader' ? 'In the Bible' : 'In this study', page)}
|
||||||
|
{section('Everywhere', GLOBAL)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,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,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,60 @@
|
|||||||
|
// Top and bottom chrome for the reader. Everything stays one tap away; focus
|
||||||
|
// mode hides both.
|
||||||
|
import { ChevronLeft, ChevronRight, ChevronDown, SearchIcon, LinkIcon, PenIcon, HeadphonesIcon, FocusIcon, CheckIcon } from './icons.jsx';
|
||||||
|
import { BookmarkIcon } from '../../utils/bibleUtils.jsx';
|
||||||
|
|
||||||
|
const iconBtn = 'flex h-9 w-9 items-center justify-center rounded-lg text-[var(--r-sub)] transition hover:bg-black/5 disabled:opacity-30 disabled:hover:bg-transparent';
|
||||||
|
|
||||||
|
export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks, bookmarkCount, onSettings, onDoneDrawing }) {
|
||||||
|
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={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,153 @@
|
|||||||
|
// 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 { formatCrossRef } from '../../utils/wordStudy.js';
|
||||||
|
|
||||||
|
// How each typeface choice is set. "Easy to read" is a sans face with extra letter and word spacing.
|
||||||
|
const FONT_STYLES = {
|
||||||
|
serif: { fontFamily: undefined },
|
||||||
|
sans: { fontFamily: "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif" },
|
||||||
|
easy: { fontFamily: "'Atkinson Hyperlegible', Verdana, 'Trebuchet MS', ui-sans-serif, sans-serif", letterSpacing: '0.02em', wordSpacing: '0.12em' },
|
||||||
|
};
|
||||||
|
const LINE_HEIGHTS = { snug: 1.6, normal: 1.85, airy: 2.15 };
|
||||||
|
|
||||||
|
// `parallel` ({ translation, name, notice, verses, loading, error }) shows a second Bible beside the first.
|
||||||
|
export default function ReaderText({
|
||||||
|
title, translation, translationId, notice, verses, loading, error, layout, fontSize, wide,
|
||||||
|
font = 'serif', spacing = 'normal', showVerseNumbers = true, parallel = null,
|
||||||
|
bookmarks, bookKey, chapter, selectedVerse, onSelectVerse, renderVerseText, searchLower,
|
||||||
|
crossRefs, showCrossRefs, onSelectionEnd, footer,
|
||||||
|
}) {
|
||||||
|
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">
|
||||||
|
<div className="mb-3 hidden grid-cols-2 gap-x-10 border-b border-[var(--r-line)] pb-1 font-sans text-[11px] uppercase tracking-wide text-[var(--r-sub)] md:grid">
|
||||||
|
<span>{translationId}</span><span>{parallel.translation}</span>
|
||||||
|
</div>
|
||||||
|
{parallelRows.map((row) => (
|
||||||
|
<div key={row.number} className="mb-4 grid gap-x-10 gap-y-1 md:grid-cols-2">
|
||||||
|
<p>
|
||||||
|
{row.first
|
||||||
|
? <span {...verseProps(row.first)}>{num(row.first.number)}{renderVerseText(row.first.number, row.first.text, searchLower)}{chips(row.first)}</span>
|
||||||
|
: <span className="text-[var(--r-sub)]">{num(row.number)}—</span>}
|
||||||
|
</p>
|
||||||
|
<p className="border-l-2 border-[var(--r-line)] pl-3 text-[0.95em] text-[var(--r-sub)] md:border-l-0 md:pl-0 md:text-[var(--r-text)]">
|
||||||
|
<span className="mr-1.5 font-sans text-[10px] uppercase tracking-wide md:hidden">{parallel.translation}</span>
|
||||||
|
{row.second ? <>{num(row.second.number)}{row.second.text}</> : parallel.loading ? <span className="font-sans text-xs" aria-label="Loading">…</span> : '—'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && !sideBySide && (
|
||||||
|
<div
|
||||||
|
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,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,73 @@
|
|||||||
|
// "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';
|
||||||
|
import { READER_TRANSLATIONS } from '../../utils/bibleText.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,
|
||||||
|
translation, parallel, onParallel,
|
||||||
|
}) {
|
||||||
|
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>
|
||||||
|
<div className={row}>
|
||||||
|
<span className={label}>Read beside another Bible</span>
|
||||||
|
<div role="group" aria-label="Second translation" className="flex flex-wrap gap-1.5">
|
||||||
|
{[{ id: '', name: 'Off' }, ...READER_TRANSLATIONS.filter((t) => t.id !== translation)].map((t) => (
|
||||||
|
<button key={t.id || 'off'} type="button" onClick={() => onParallel(t.id)} aria-pressed={parallel === t.id} title={t.name}
|
||||||
|
className={`rounded-full border px-3 py-1 text-xs font-medium ${parallel === t.id ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
|
||||||
|
{t.id || 'Off'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 text-[11px] text-[var(--r-sub)]">Shows both side by side on wider screens, and stacked on a phone.</p>
|
||||||
|
</div>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,141 @@
|
|||||||
|
// 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';
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{READER_TRANSLATIONS.map((t) => {
|
||||||
|
const row = rows[t.id];
|
||||||
|
return (
|
||||||
|
<div key={t.id}>
|
||||||
|
<p className="mb-0.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">{t.id}{t.id === current ? ' · reading' : ''}</p>
|
||||||
|
{!row ? <p className="text-sm text-[var(--r-sub)]">Loading…</p>
|
||||||
|
: row.error ? <p className="text-sm text-[var(--r-sub)]">{row.error}</p>
|
||||||
|
: <p className="font-serif text-[15px] leading-relaxed">{row.text}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<p className="border-t border-[var(--r-line)] pt-2 text-[10px] leading-snug text-[var(--r-sub)]">Copyright notices for each translation appear at the foot of the chapter when you read it.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VerseSheet({
|
||||||
|
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,13 @@
|
|||||||
|
// 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>;
|
||||||
@@ -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,51 @@ 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 [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 +138,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 +149,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 +207,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 +271,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 +362,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 +410,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 +452,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 +485,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 +527,13 @@ 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,
|
||||||
|
planTranslationPref, setPlanTranslationPref,
|
||||||
|
startPagePref, setStartPagePref,
|
||||||
readerBookmarks, setReaderBookmarks,
|
readerBookmarks, setReaderBookmarks,
|
||||||
readerBookmarksPanelOpen, setReaderBookmarksPanelOpen,
|
readerBookmarksPanelOpen, setReaderBookmarksPanelOpen,
|
||||||
readerCrossRefs, setReaderCrossRefs,
|
readerCrossRefs, setReaderCrossRefs,
|
||||||
@@ -312,10 +545,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 +562,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,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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { renderHook, act } from '@testing-library/react';
|
||||||
|
import { vi, test, expect, beforeEach } from 'vitest';
|
||||||
|
import { useResizableWidth } from './useResizableWidth.js';
|
||||||
|
|
||||||
|
beforeEach(() => { localStorage.clear(); });
|
||||||
|
|
||||||
|
// A container 1400px wide whose right edge is at x=1400, and a panel currently 448px wide.
|
||||||
|
const setup = (opts = {}) => {
|
||||||
|
const style = { setProperty: vi.fn(), removeProperty: vi.fn() };
|
||||||
|
const containerRef = { current: { getBoundingClientRect: () => ({ right: 1400, width: 1400 }), style } };
|
||||||
|
const panelRef = { current: { getBoundingClientRect: () => ({ width: 448 }) } };
|
||||||
|
const hook = renderHook(() => useResizableWidth({ storageKey: 'w', containerRef, ...opts }));
|
||||||
|
const handle = () => hook.result.current.handleProps(panelRef);
|
||||||
|
return { hook, handle, style };
|
||||||
|
};
|
||||||
|
const target = { setPointerCapture: vi.fn(), releasePointerCapture: vi.fn() };
|
||||||
|
const down = (h) => h.handle().onPointerDown({ button: 0, pointerId: 1, currentTarget: target });
|
||||||
|
const move = (h, clientX) => h.handle().onPointerMove({ clientX });
|
||||||
|
const up = (h) => h.handle().onPointerUp({ pointerId: 1, currentTarget: target });
|
||||||
|
|
||||||
|
test('dragging sets the width from the pointer to the right edge, then remembers it', () => {
|
||||||
|
const h = setup();
|
||||||
|
act(() => { down(h); move(h, 900); });
|
||||||
|
expect(h.style.setProperty).toHaveBeenLastCalledWith('--study-right', '500px'); // 1400 - 900
|
||||||
|
expect(h.hook.result.current.width).toBeNull(); // no React render per pointer move
|
||||||
|
act(() => up(h));
|
||||||
|
expect(h.hook.result.current.width).toBe(500);
|
||||||
|
expect(localStorage.getItem('w')).toBe('500');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the width is kept between a minimum and a maximum that leaves room for the passage', () => {
|
||||||
|
const h = setup();
|
||||||
|
act(() => { down(h); move(h, 1350); }); // would be 50px
|
||||||
|
expect(h.style.setProperty).toHaveBeenLastCalledWith('--study-right', '320px');
|
||||||
|
act(() => { move(h, 100); }); // would be 1300px; the container leaves 1400 - 600 = 800, capped at 760
|
||||||
|
expect(h.style.setProperty).toHaveBeenLastCalledWith('--study-right', '760px');
|
||||||
|
act(() => up(h));
|
||||||
|
expect(h.hook.result.current.width).toBe(760);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a move without a press does nothing', () => {
|
||||||
|
const h = setup();
|
||||||
|
act(() => move(h, 900));
|
||||||
|
expect(h.style.setProperty).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('arrow keys resize from the current width; shift takes bigger steps', () => {
|
||||||
|
const h = setup();
|
||||||
|
act(() => h.handle().onKeyDown({ key: 'ArrowLeft', preventDefault() {} }));
|
||||||
|
expect(h.hook.result.current.width).toBe(472); // 448 + 24: wider
|
||||||
|
act(() => h.handle().onKeyDown({ key: 'ArrowRight', shiftKey: true, preventDefault() {} }));
|
||||||
|
expect(h.hook.result.current.width).toBe(376); // 472 - 96: narrower
|
||||||
|
expect(localStorage.getItem('w')).toBe('376');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Home, Escape and double-click go back to the default width', () => {
|
||||||
|
localStorage.setItem('w', '600');
|
||||||
|
const h = setup();
|
||||||
|
expect(h.hook.result.current.width).toBe(600);
|
||||||
|
act(() => h.handle().onKeyDown({ key: 'Home', preventDefault() {} }));
|
||||||
|
expect(h.hook.result.current.width).toBeNull();
|
||||||
|
expect(localStorage.getItem('w')).toBeNull();
|
||||||
|
expect(h.style.removeProperty).toHaveBeenCalledWith('--study-right');
|
||||||
|
act(() => h.handle().onKeyDown({ key: 'ArrowLeft', preventDefault() {} }));
|
||||||
|
act(() => h.handle().onDoubleClick());
|
||||||
|
expect(h.hook.result.current.width).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a remembered width is restored, kept within the limits, and junk is ignored', () => {
|
||||||
|
localStorage.setItem('w', '100');
|
||||||
|
expect(setup().hook.result.current.width).toBe(320);
|
||||||
|
localStorage.setItem('w', '5000');
|
||||||
|
expect(setup().hook.result.current.width).toBe(760);
|
||||||
|
localStorage.setItem('w', 'wide');
|
||||||
|
expect(setup().hook.result.current.width).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the handle describes itself for assistive technology', () => {
|
||||||
|
const props = setup().handle();
|
||||||
|
expect(props).toMatchObject({ role: 'separator', 'aria-orientation': 'vertical', 'aria-label': 'Resize side panel', tabIndex: 0, 'aria-valuenow': 448 });
|
||||||
|
});
|
||||||
@@ -5,8 +5,12 @@ import { useEffect, useState } from 'react';
|
|||||||
const commentaryCache = {}; // "commentaryId/BOOK/chapter" -> data
|
const commentaryCache = {}; // "commentaryId/BOOK/chapter" -> data
|
||||||
const interlinearCache = {}; // BOOK -> whole-book interlinear data
|
const interlinearCache = {}; // BOOK -> whole-book interlinear data
|
||||||
|
|
||||||
export function useStudyResources({ project, selectedChunk, selectedChunkChapterIndex, collapsedSections }) {
|
// `commentarySource` / `setCommentarySource` may be passed in to share the choice with the rest of
|
||||||
const [commentarySource, setCommentarySource] = useState('matthew-henry');
|
// the app (it's a synced preference); otherwise the hook keeps its own.
|
||||||
|
export function useStudyResources({ project, selectedChunk, selectedChunkChapterIndex, collapsedSections, commentarySource: sharedSource, setCommentarySource: setSharedSource }) {
|
||||||
|
const [ownSource, setOwnSource] = useState('matthew-henry');
|
||||||
|
const commentarySource = sharedSource ?? ownSource;
|
||||||
|
const setCommentarySource = setSharedSource ?? setOwnSource;
|
||||||
const [commentaryData, setCommentaryData] = useState(null); // { content: [...] } for current chapter
|
const [commentaryData, setCommentaryData] = useState(null); // { content: [...] } for current chapter
|
||||||
const [commentaryLoading, setCommentaryLoading] = useState(false);
|
const [commentaryLoading, setCommentaryLoading] = useState(false);
|
||||||
const [commentaryError, setCommentaryError] = useState('');
|
const [commentaryError, setCommentaryError] = useState('');
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// Free study data from HelloAO: public-domain commentaries and the Theographic
|
||||||
|
// people/places/events dataset. Both are keyed by book + chapter, so results are
|
||||||
|
// cached per chapter for the rest of the visit.
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { renderVerseContent } from '../utils/bibleUtils.jsx';
|
||||||
|
|
||||||
|
const API = 'https://bible.helloao.org/api';
|
||||||
|
|
||||||
|
export const COMMENTARIES = [
|
||||||
|
{ id: 'matthew-henry', name: 'Matthew Henry', credit: 'Matthew Henry Bible Commentary (public domain).' },
|
||||||
|
{ id: 'john-gill', name: 'John Gill', credit: 'John Gill Bible Commentary (public domain).' },
|
||||||
|
{ id: 'jamieson-fausset-brown', name: 'Jamieson-Fausset-Brown', credit: 'Jamieson-Fausset-Brown Bible Commentary (public domain).' },
|
||||||
|
{ id: 'tyndale', name: 'Tyndale Study Notes', credit: 'Tyndale Open Study Notes © Tyndale House Foundation, licensed CC BY-SA 4.0.' },
|
||||||
|
{ id: 'adam-clarke', name: 'Adam Clarke', credit: 'Adam Clarke Bible Commentary (public domain).' },
|
||||||
|
{ id: 'john-calvin', name: 'John Calvin', credit: "John Calvin's Commentaries (public domain)." },
|
||||||
|
{ id: 'keil-delitzsch', name: 'Keil & Delitzsch (OT)', credit: 'Keil & Delitzsch Old Testament Commentary (public domain).' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const commentaryCache = new Map(); // "id/BOOK/ch" -> Promise<entries | null>
|
||||||
|
const theographicCache = new Map(); // "BOOK/ch" -> Promise<{people, places, events} | null>
|
||||||
|
const personCache = new Map(); // id -> Promise<person | null>
|
||||||
|
|
||||||
|
const getJson = (url) => fetch(url).then((r) => (r.ok ? r.json() : null)).catch(() => null);
|
||||||
|
|
||||||
|
// Resolves to [{ number, text }] sorted by verse, or null when the commentary
|
||||||
|
// doesn't cover that book/chapter.
|
||||||
|
export function loadCommentary(id, book, chapter) {
|
||||||
|
const key = `${id}/${book}/${chapter}`;
|
||||||
|
if (!commentaryCache.has(key)) {
|
||||||
|
commentaryCache.set(key, getJson(`${API}/c/${id}/${book}/${chapter}.json`).then((data) => {
|
||||||
|
const items = data?.chapter?.content;
|
||||||
|
if (!Array.isArray(items)) return null;
|
||||||
|
return items
|
||||||
|
.filter((it) => it?.type === 'verse')
|
||||||
|
.map((it) => ({ number: it.number, text: renderVerseContent(it.content).replace(/[ \t]+\n/g, '\n').trim() }))
|
||||||
|
.filter((it) => it.text)
|
||||||
|
.sort((a, b) => a.number - b.number);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return commentaryCache.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadTheographic(book, chapter) {
|
||||||
|
const key = `${book}/${chapter}`;
|
||||||
|
if (!theographicCache.has(key)) {
|
||||||
|
theographicCache.set(key, getJson(`${API}/d/theographic/${book}/${chapter}.json`).then((data) => (
|
||||||
|
data?.chapter ? { people: data.chapter.people ?? [], places: data.chapter.places ?? [], events: data.chapter.events ?? [] } : null
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
return theographicCache.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadPerson(id) {
|
||||||
|
if (!personCache.has(id)) {
|
||||||
|
personCache.set(id, getJson(`${API}/d/theographic/people/${id}.json`).then((d) => d?.person ?? null));
|
||||||
|
}
|
||||||
|
return personCache.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For verse N, the commentary entry that starts at N, or otherwise the closest
|
||||||
|
// earlier one (several commentaries write on whole passages, not each verse).
|
||||||
|
export function pickCommentaryEntry(entries, verse) {
|
||||||
|
if (!entries?.length) return null;
|
||||||
|
let best = null;
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e.number <= verse) best = e;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small state wrapper: { status: 'loading' | 'ready' | 'empty', data }.
|
||||||
|
export function useAsync(loader, deps) {
|
||||||
|
const [state, setState] = useState({ status: 'loading', data: null });
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
setState({ status: 'loading', data: null });
|
||||||
|
loader().then((data) => {
|
||||||
|
if (active) setState({ status: data ? 'ready' : 'empty', data });
|
||||||
|
});
|
||||||
|
return () => { active = false; };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, deps);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@ export function useWordStudy({ project, allChunks, updateProject, notify }) {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Adds a blank word entry and returns its id (so the UI can open it for editing).
|
||||||
const addGreekWord = (chunkId) => {
|
const addGreekWord = (chunkId) => {
|
||||||
const newWord = {
|
const newWord = {
|
||||||
id: makeId(),
|
id: makeId(),
|
||||||
@@ -70,6 +71,42 @@ export function useWordStudy({ project, allChunks, updateProject, notify }) {
|
|||||||
),
|
),
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
return newWord.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Adds a fully filled-in entry for an original word picked from the interlinear
|
||||||
|
// (Strong's number, lexeme, transliteration and gloss come with the click).
|
||||||
|
const addWordFromInterlinear = async (chunkId, w) => {
|
||||||
|
const chunk = allChunks.find((c) => c.id === chunkId);
|
||||||
|
const key = String(w?.s ?? '').toUpperCase();
|
||||||
|
if (!chunk || !key) return null;
|
||||||
|
if (chunk.greekWords.some((x) => (x.strongNumber || '').toUpperCase() === key)) {
|
||||||
|
notify('That word is already in this chunk.', 1800);
|
||||||
|
return chunk.greekWords.find((x) => (x.strongNumber || '').toUpperCase() === key).id;
|
||||||
|
}
|
||||||
|
let entry = null;
|
||||||
|
try { entry = (key.startsWith('H') ? await loadHebrewDict() : await loadGreekDict())[key] ?? null; } catch { /* dictionary unavailable — keep what the click gave us */ }
|
||||||
|
const newWord = {
|
||||||
|
id: makeId(),
|
||||||
|
query: key,
|
||||||
|
strongNumber: key,
|
||||||
|
englishGloss: w.g || '',
|
||||||
|
lexeme: w.o || entry?.lemma || '',
|
||||||
|
transliteration: w.t || entry?.translit || entry?.xlit || '',
|
||||||
|
partOfSpeech: '',
|
||||||
|
shortDefinition: entry?.kjv_def || w.g || '',
|
||||||
|
definitionHtml: entry ? buildGreekDefinitionHtml(key, entry) : '',
|
||||||
|
loading: false,
|
||||||
|
};
|
||||||
|
updateProject((current) => ({
|
||||||
|
...current,
|
||||||
|
chapters: current.chapters.map((ch) => ({
|
||||||
|
...ch,
|
||||||
|
chunks: ch.chunks.map((c) => (c.id === chunkId ? { ...c, greekWords: [...c.greekWords, newWord] } : c)),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
notify(`Added ${newWord.lexeme || key} to word study.`, 1800);
|
||||||
|
return newWord.id;
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeGreekWord = (chunkId, wordId) => {
|
const removeGreekWord = (chunkId, wordId) => {
|
||||||
@@ -332,6 +369,7 @@ export function useWordStudy({ project, allChunks, updateProject, notify }) {
|
|||||||
suggestSelection, setSuggestSelection,
|
suggestSelection, setSuggestSelection,
|
||||||
updateChunkWord,
|
updateChunkWord,
|
||||||
addGreekWord,
|
addGreekWord,
|
||||||
|
addWordFromInterlinear,
|
||||||
removeGreekWord,
|
removeGreekWord,
|
||||||
lookupWord,
|
lookupWord,
|
||||||
suggestGreekWordsForChunk,
|
suggestGreekWordsForChunk,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ body,
|
|||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: 'Inter', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
font-family: 'Inter', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
background: #f8fafc;
|
background: #faf7f0; /* the default (paper) theme; replaced by the chosen theme at runtime */
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,12 +30,11 @@ select {
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* When installed as a PWA, push header content below the status bar */
|
/* Top bars leave room for the status bar / notch when the app is installed or full-screen
|
||||||
@media all and (display-mode: standalone) {
|
(env() is 0 in an ordinary browser tab). */
|
||||||
header {
|
.safe-top {
|
||||||
padding-top: env(safe-area-inset-top, 0px);
|
padding-top: env(safe-area-inset-top, 0px);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.scrollbar-thin::-webkit-scrollbar {
|
.scrollbar-thin::-webkit-scrollbar {
|
||||||
width: 9px;
|
width: 9px;
|
||||||
@@ -50,3 +49,36 @@ select {
|
|||||||
.scrollbar-thin::-webkit-scrollbar-track {
|
.scrollbar-thin::-webkit-scrollbar-track {
|
||||||
background: rgba(148, 163, 184, 0.12);
|
background: rgba(148, 163, 184, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A clear ring wherever keyboard focus lands (mouse clicks don't show it). */
|
||||||
|
:where(button, a, summary, [role='button'], [role='tab'], [role='option'], input, select, textarea):focus-visible {
|
||||||
|
outline: 2px solid var(--r-accent, #a84e27);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "Skip to content" appears only when it's tabbed to. */
|
||||||
|
.skip-link {
|
||||||
|
position: absolute;
|
||||||
|
left: 0.75rem;
|
||||||
|
top: -4rem;
|
||||||
|
z-index: 100;
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: var(--r-text, #2b2620);
|
||||||
|
color: var(--r-bg, #faf7f0);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.skip-link:focus {
|
||||||
|
top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useApp } from '../context/AppContext.js';
|
import { useApp } from '../context/AppContext.js';
|
||||||
import AuthStatus from '../components/AuthStatus.jsx';
|
import PageShell from '../components/study/PageShell.jsx';
|
||||||
import { buildExportHtml } from '../utils/bibleUtils.jsx';
|
import { buildExportHtml } from '../utils/bibleUtils.jsx';
|
||||||
import {
|
import {
|
||||||
adminListUsers,
|
adminListUsers,
|
||||||
@@ -68,46 +68,27 @@ export default function AdminPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
<PageShell title="Admin" onBack={goHome} maxWidth="max-w-5xl">
|
||||||
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm">
|
<main className="mx-auto max-w-5xl space-y-5 px-4 py-6">
|
||||||
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-4 py-5 sm:px-6 lg:px-8">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p>
|
|
||||||
<h1 className="mt-2 text-2xl font-semibold">🛡 Admin</h1>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={goHome}
|
|
||||||
className="rounded-xl border border-slate-500 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-700"
|
|
||||||
>
|
|
||||||
← Back
|
|
||||||
</button>
|
|
||||||
<AuthStatus />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="mx-auto max-w-5xl px-4 py-8 sm:px-6 lg:px-8 space-y-6">
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button type="button" onClick={() => setAdminTab('users')}
|
<button type="button" onClick={() => setAdminTab('users')}
|
||||||
className={`rounded-lg px-4 py-2 text-sm font-semibold transition ${adminTab === 'users' ? 'bg-slate-900 text-white' : 'border border-slate-300 bg-white text-slate-600 hover:bg-slate-50'}`}>
|
className={`rounded-full px-4 py-1.5 text-sm transition ${adminTab === 'users' ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'border border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
|
||||||
Users ({adminUsers.length})
|
Users ({adminUsers.length})
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => setAdminTab('projects')}
|
<button type="button" onClick={() => setAdminTab('projects')}
|
||||||
className={`rounded-lg px-4 py-2 text-sm font-semibold transition ${adminTab === 'projects' ? 'bg-slate-900 text-white' : 'border border-slate-300 bg-white text-slate-600 hover:bg-slate-50'}`}>
|
className={`rounded-full px-4 py-1.5 text-sm transition ${adminTab === 'projects' ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'border border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
|
||||||
Projects ({adminProjects.length})
|
Projects ({adminProjects.length})
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{adminLoading && <p className="text-sm text-slate-500">Loading…</p>}
|
{adminLoading && <p className="text-sm text-[var(--r-sub)]">Loading…</p>}
|
||||||
{adminError && <p className="text-sm text-rose-600">{adminError}</p>}
|
{adminError && <p className="text-sm text-[var(--r-danger,#b42318)]">{adminError}</p>}
|
||||||
|
|
||||||
{!adminLoading && !adminError && adminTab === 'users' && (
|
{!adminLoading && !adminError && adminTab === 'users' && (
|
||||||
<div className="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-panel">
|
<div className="overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)]">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full min-w-[560px] text-sm">
|
<table className="w-full min-w-[560px] text-sm">
|
||||||
<thead className="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
<thead className="bg-[var(--r-bg)] text-left text-xs font-medium uppercase tracking-wide text-[var(--r-sub)]">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3">Email</th>
|
<th className="px-4 py-3">Email</th>
|
||||||
<th className="px-4 py-3">Joined</th>
|
<th className="px-4 py-3">Joined</th>
|
||||||
@@ -118,22 +99,22 @@ export default function AdminPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{adminUsers.map((u) => (
|
{adminUsers.map((u) => (
|
||||||
<tr key={u.id} className="border-t border-slate-100">
|
<tr key={u.id} className="border-t border-[var(--r-line)]">
|
||||||
<td className="px-4 py-3 font-medium text-slate-800">
|
<td className="px-4 py-3 font-medium text-[var(--r-text)]">
|
||||||
{u.email}{u.id === authUser.id && <span className="ml-2 text-xs font-normal text-slate-400">(you)</span>}
|
{u.email}{u.id === authUser.id && <span className="ml-2 text-xs font-normal text-[var(--r-sub)]">(you)</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-slate-500">{new Date(u.createdAt).toLocaleDateString()}</td>
|
<td className="px-4 py-3 text-[var(--r-sub)]">{new Date(u.createdAt).toLocaleDateString()}</td>
|
||||||
<td className="px-4 py-3 text-slate-500">{u.totpEnabled ? '✓' : '—'}</td>
|
<td className="px-4 py-3 text-[var(--r-sub)]">{u.totpEnabled ? '✓' : '—'}</td>
|
||||||
<td className="px-4 py-3 text-slate-500">{u.projectCount}</td>
|
<td className="px-4 py-3 text-[var(--r-sub)]">{u.projectCount}</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
{u.id !== authUser.id && (
|
{u.id !== authUser.id && (
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<button type="button" onClick={() => handleResetPasswordAdmin(u.id, u.email)}
|
<button type="button" onClick={() => handleResetPasswordAdmin(u.id, u.email)}
|
||||||
className="rounded-lg border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50">
|
className="rounded-lg border border-[var(--r-line)] px-3 py-1 text-xs hover:bg-black/5">
|
||||||
Reset Password
|
Reset Password
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => handleDeleteUserAdmin(u.id, u.email)}
|
<button type="button" onClick={() => handleDeleteUserAdmin(u.id, u.email)}
|
||||||
className="rounded-lg border border-rose-200 px-3 py-1 text-xs font-semibold text-rose-600 hover:bg-rose-50">
|
className="rounded-lg border border-[var(--r-line)] px-3 py-1 text-xs text-[var(--r-danger,#b42318)] hover:bg-black/5">
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -148,10 +129,10 @@ export default function AdminPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!adminLoading && !adminError && adminTab === 'projects' && (
|
{!adminLoading && !adminError && adminTab === 'projects' && (
|
||||||
<div className="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-panel">
|
<div className="overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)]">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full min-w-[640px] text-sm">
|
<table className="w-full min-w-[640px] text-sm">
|
||||||
<thead className="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
<thead className="bg-[var(--r-bg)] text-left text-xs font-medium uppercase tracking-wide text-[var(--r-sub)]">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3">Title</th>
|
<th className="px-4 py-3">Title</th>
|
||||||
<th className="px-4 py-3">Owner</th>
|
<th className="px-4 py-3">Owner</th>
|
||||||
@@ -163,22 +144,22 @@ export default function AdminPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{adminProjects.map((p) => (
|
{adminProjects.map((p) => (
|
||||||
<tr key={p.id} className="border-t border-slate-100">
|
<tr key={p.id} className="border-t border-[var(--r-line)]">
|
||||||
<td className="px-4 py-3 font-medium text-slate-800">{p.title}</td>
|
<td className="px-4 py-3 font-medium text-[var(--r-text)]">{p.title}</td>
|
||||||
<td className="px-4 py-3 text-slate-500">
|
<td className="px-4 py-3 text-[var(--r-sub)]">
|
||||||
{p.ownerEmail ?? <span className="italic text-slate-400">orphaned</span>}
|
{p.ownerEmail ?? <span className="italic text-[var(--r-sub)]">orphaned</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-slate-500">{p.chapterSummary}</td>
|
<td className="px-4 py-3 text-[var(--r-sub)]">{p.chapterSummary}</td>
|
||||||
<td className="px-4 py-3 text-slate-500">{new Date(p.lastEdited).toLocaleDateString()}</td>
|
<td className="px-4 py-3 text-[var(--r-sub)]">{new Date(p.lastEdited).toLocaleDateString()}</td>
|
||||||
<td className="px-4 py-3 text-slate-500">{p.shareToken ? '🔗' : '—'}</td>
|
<td className="px-4 py-3 text-[var(--r-sub)]">{p.shareToken ? '🔗' : '—'}</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<button type="button" onClick={() => handleViewProjectAdmin(p.id)}
|
<button type="button" onClick={() => handleViewProjectAdmin(p.id)}
|
||||||
className="rounded-lg border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50">
|
className="rounded-lg border border-[var(--r-line)] px-3 py-1 text-xs hover:bg-black/5">
|
||||||
View
|
View
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => handleDeleteProjectAdmin(p.id, p.title)}
|
<button type="button" onClick={() => handleDeleteProjectAdmin(p.id, p.title)}
|
||||||
className="rounded-lg border border-rose-200 px-3 py-1 text-xs font-semibold text-rose-600 hover:bg-rose-50">
|
className="rounded-lg border border-[var(--r-line)] px-3 py-1 text-xs text-[var(--r-danger,#b42318)] hover:bg-black/5">
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -194,11 +175,11 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
{adminViewProject && (
|
{adminViewProject && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setAdminViewProject(null)}>
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setAdminViewProject(null)}>
|
||||||
<div className="h-full w-full max-w-4xl overflow-hidden rounded-2xl bg-white shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
<div className="h-full w-full max-w-4xl overflow-hidden rounded-2xl bg-[var(--r-bar)] shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="flex items-center justify-between border-b border-slate-200 px-4 py-3">
|
<div className="flex items-center justify-between border-b border-[var(--r-line)] px-4 py-3">
|
||||||
<p className="text-sm font-semibold text-slate-700">{adminViewProject.title}</p>
|
<p className="text-sm font-medium text-[var(--r-text)]">{adminViewProject.title}</p>
|
||||||
<button type="button" onClick={() => setAdminViewProject(null)}
|
<button type="button" onClick={() => setAdminViewProject(null)}
|
||||||
className="rounded-lg border border-slate-300 px-3 py-1 text-xs text-slate-600 hover:bg-slate-50">
|
className="rounded-lg border border-[var(--r-line)] px-3 py-1 text-xs hover:bg-black/5">
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -214,22 +195,22 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
{adminResetResult && (
|
{adminResetResult && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||||
<div className="w-full max-w-sm rounded-2xl bg-white p-6 shadow-2xl">
|
<div className="w-full max-w-sm rounded-2xl bg-[var(--r-bar)] p-6 shadow-2xl">
|
||||||
<h3 className="text-sm font-semibold text-slate-900">Password reset</h3>
|
<h3 className="text-sm font-medium text-[var(--r-text)]">Password reset</h3>
|
||||||
<p className="mt-1 text-xs text-slate-500">
|
<p className="mt-1 text-xs text-[var(--r-sub)]">
|
||||||
Relay this to <span className="font-medium text-slate-700">{adminResetResult.email}</span> yourself
|
Relay this to <span className="font-medium text-[var(--r-text)]">{adminResetResult.email}</span> yourself
|
||||||
(text, call, in person) — it won't be shown again. They're signed out everywhere until they log in with it.
|
(text, call, in person) — it won't be shown again. They're signed out everywhere until they log in with it.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-center font-mono text-lg tracking-wider text-amber-800">
|
<p className="mt-4 rounded-xl border border-[var(--r-accent)] bg-[var(--r-bg)] px-3 py-2 text-center font-mono text-lg tracking-wider text-[var(--r-accent)]">
|
||||||
{adminResetResult.temporaryPassword}
|
{adminResetResult.temporaryPassword}
|
||||||
</p>
|
</p>
|
||||||
<button type="button" onClick={() => setAdminResetResult(null)}
|
<button type="button" onClick={() => setAdminResetResult(null)}
|
||||||
className="mt-4 w-full rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400">
|
className="mt-4 w-full rounded-full bg-[var(--r-accent)] px-5 py-2 text-sm font-medium text-[var(--r-on)] transition hover:opacity-90">
|
||||||
Done
|
Done
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useApp } from '../context/AppContext.js';
|
import { useApp } from '../context/AppContext.js';
|
||||||
|
import { themeVars } from '../components/reader/readerThemes.js';
|
||||||
import { loginUser, registerUser, verifyMfaLogin, logoutUser } from '../syncService.js';
|
import { loginUser, registerUser, verifyMfaLogin, logoutUser } from '../syncService.js';
|
||||||
|
|
||||||
export default function AuthPage() {
|
export default function AuthPage() {
|
||||||
const { signIn } = useApp();
|
const { signIn, readerTheme } = useApp();
|
||||||
|
const shell = { style: themeVars(readerTheme) };
|
||||||
const [authMode, setAuthMode] = useState('login'); // 'login' | 'register'
|
const [authMode, setAuthMode] = useState('login'); // 'login' | 'register'
|
||||||
const [authForm, setAuthForm] = useState({ email: '', password: '' });
|
const [authForm, setAuthForm] = useState({ email: '', password: '' });
|
||||||
const [authError, setAuthError] = useState('');
|
const [authError, setAuthError] = useState('');
|
||||||
@@ -49,11 +51,11 @@ export default function AuthPage() {
|
|||||||
|
|
||||||
if (authMfaPending) {
|
if (authMfaPending) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-slate-900 px-4">
|
<div {...shell} className="flex min-h-dvh items-center justify-center bg-[var(--r-bg)] px-4 text-[var(--r-text)]">
|
||||||
<div className="w-full max-w-sm rounded-3xl border border-white/10 bg-white p-8 shadow-panel">
|
<div className="w-full max-w-sm rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-8">
|
||||||
<p className="text-sm uppercase tracking-[0.24em] text-slate-400">Bible Study Project</p>
|
<p className="text-sm uppercase tracking-[0.24em] text-[var(--r-sub)]">Bible Study Project</p>
|
||||||
<h1 className="mt-2 text-xl font-semibold text-slate-900">Two-factor verification</h1>
|
<h1 className="mt-2 text-xl font-medium text-[var(--r-text)]">Two-factor verification</h1>
|
||||||
<p className="mt-2 text-sm text-slate-500">
|
<p className="mt-2 text-sm text-[var(--r-sub)]">
|
||||||
{authMfaUseBackup
|
{authMfaUseBackup
|
||||||
? 'Enter one of your saved backup codes.'
|
? 'Enter one of your saved backup codes.'
|
||||||
: 'Enter the 6-digit code from your authenticator app.'}
|
: 'Enter the 6-digit code from your authenticator app.'}
|
||||||
@@ -67,13 +69,13 @@ export default function AuthPage() {
|
|||||||
placeholder={authMfaUseBackup ? 'xxxxxxxxxx' : '123456'}
|
placeholder={authMfaUseBackup ? 'xxxxxxxxxx' : '123456'}
|
||||||
value={authMfaCode}
|
value={authMfaCode}
|
||||||
onChange={(e) => setAuthMfaCode(e.target.value)}
|
onChange={(e) => setAuthMfaCode(e.target.value)}
|
||||||
className="block w-full rounded-xl border border-slate-300 px-3 py-2 text-center text-lg tracking-widest shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
className="block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-[var(--r-text)] text-center text-lg tracking-widest focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]"
|
||||||
/>
|
/>
|
||||||
{authError && <p className="text-sm text-rose-600">{authError}</p>}
|
{authError && <p className="text-sm text-[var(--r-danger,#b42318)]">{authError}</p>}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={authBusy}
|
disabled={authBusy}
|
||||||
className="w-full rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:bg-slate-300"
|
className="w-full rounded-full bg-[var(--r-accent)] px-4 py-2.5 text-sm font-medium text-[var(--r-on)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{authBusy ? 'Verifying…' : 'Verify'}
|
{authBusy ? 'Verifying…' : 'Verify'}
|
||||||
</button>
|
</button>
|
||||||
@@ -85,7 +87,7 @@ export default function AuthPage() {
|
|||||||
setAuthMfaCode('');
|
setAuthMfaCode('');
|
||||||
setAuthError('');
|
setAuthError('');
|
||||||
}}
|
}}
|
||||||
className="mt-4 w-full text-center text-sm text-slate-500 underline hover:text-slate-700"
|
className="mt-4 w-full text-center text-sm text-[var(--r-sub)] underline hover:text-[var(--r-text)]"
|
||||||
>
|
>
|
||||||
{authMfaUseBackup ? 'Use your authenticator app instead' : "Lost your device? Use a backup code"}
|
{authMfaUseBackup ? 'Use your authenticator app instead' : "Lost your device? Use a backup code"}
|
||||||
</button>
|
</button>
|
||||||
@@ -97,7 +99,7 @@ export default function AuthPage() {
|
|||||||
setAuthMfaCode('');
|
setAuthMfaCode('');
|
||||||
setAuthError('');
|
setAuthError('');
|
||||||
}}
|
}}
|
||||||
className="mt-2 w-full text-center text-sm text-slate-400 underline hover:text-slate-600"
|
className="mt-2 w-full text-center text-sm text-[var(--r-sub)] underline hover:text-[var(--r-sub)]"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -107,41 +109,41 @@ export default function AuthPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-slate-900 px-4 py-12">
|
<div {...shell} className="flex min-h-dvh items-center justify-center bg-[var(--r-bg)] px-4 py-12 text-[var(--r-text)]">
|
||||||
<div className="grid w-full max-w-5xl items-center gap-16 lg:grid-cols-2">
|
<div className="grid w-full max-w-5xl items-center gap-16 lg:grid-cols-2">
|
||||||
{/* Branding / feature panel — hidden on small screens to keep the form front and center there */}
|
{/* Branding / feature panel — hidden on small screens to keep the form front and center there */}
|
||||||
<div className="hidden lg:block">
|
<div className="hidden lg:block">
|
||||||
<p className="text-sm uppercase tracking-[0.24em] text-slate-400">Bible Study Project</p>
|
<p className="text-sm uppercase tracking-[0.24em] text-[var(--r-sub)]">Bible Study Project</p>
|
||||||
<h1 className="mt-4 text-3xl font-semibold leading-tight text-white">
|
<h1 className="mt-4 text-3xl font-semibold leading-tight text-[var(--r-text)]">
|
||||||
Deep Bible study,<br />organized chunk by chunk.
|
Deep Bible study,<br />one chunk at a time.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-4 max-w-sm text-slate-300">
|
<p className="mt-4 max-w-sm text-[var(--r-sub)]">
|
||||||
Split any passage into chunks and work through Observation, Interpretation, and
|
Read Scripture in six translations, follow a reading plan into the text, and study a passage one chunk
|
||||||
Application notes alongside Greek & Hebrew word studies, cross-references, and commentary.
|
at a time with commentary, word studies, and cross-references beside it.
|
||||||
</p>
|
</p>
|
||||||
<ul className="mt-8 space-y-4 text-sm text-slate-300">
|
<ul className="mt-8 space-y-3 text-sm text-[var(--r-sub)]">
|
||||||
<li className="flex items-center gap-3"><span className="text-lg">📖</span> Chunk-by-chunk OIA notes on any passage</li>
|
<li>Read in BSB, ESV, NLT, CSB, NASB, or NET, with audio and search</li>
|
||||||
<li className="flex items-center gap-3"><span className="text-lg">🔤</span> Greek & Hebrew word studies with pronunciation</li>
|
<li>Reading plans that open straight into the Reader</li>
|
||||||
<li className="flex items-center gap-3"><span className="text-lg">🔗</span> Cross-references and commentary, one click away</li>
|
<li>Observe, Interpret, Apply notes with Greek & Hebrew word studies</li>
|
||||||
<li className="flex items-center gap-3"><span className="text-lg">🎙</span> Turn your notes into a podcast-ready script</li>
|
<li>Turn your notes into a script, then export or share</li>
|
||||||
<li className="flex items-center gap-3"><span className="text-lg">🔒</span> Your studies stay private to your account, with optional 2FA</li>
|
<li>Your studies stay private to your account, with optional 2FA</li>
|
||||||
</ul>
|
</ul>
|
||||||
<blockquote className="mt-8 border-l-2 border-slate-700 pl-4 text-sm italic text-slate-400">
|
<blockquote className="mt-8 border-l-2 border-[var(--r-line)] pl-4 text-sm italic text-[var(--r-sub)]">
|
||||||
"Make every effort to present yourself approved to God, an unashamed workman who accurately
|
"Make every effort to present yourself approved to God, an unashamed workman who accurately
|
||||||
handles the word of truth."
|
handles the word of truth."
|
||||||
<footer className="mt-1 not-italic text-slate-500">— 2 Timothy 2:15 (BSB)</footer>
|
<footer className="mt-1 not-italic text-[var(--r-sub)]">— 2 Timothy 2:15 (BSB)</footer>
|
||||||
</blockquote>
|
</blockquote>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sign in / register form */}
|
{/* Sign in / register form */}
|
||||||
<div className="flex justify-center lg:justify-start">
|
<div className="flex justify-center lg:justify-start">
|
||||||
<div className="w-full max-w-sm rounded-3xl border border-white/10 bg-white p-8 shadow-panel">
|
<div className="w-full max-w-sm rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-8">
|
||||||
<p className="text-sm uppercase tracking-[0.24em] text-slate-400 lg:hidden">Bible Study Project</p>
|
<p className="text-sm uppercase tracking-[0.24em] text-[var(--r-sub)] lg:hidden">Bible Study Project</p>
|
||||||
<h1 className="mt-2 text-xl font-semibold text-slate-900">
|
<h1 className="mt-2 text-xl font-medium text-[var(--r-text)]">
|
||||||
{authMode === 'login' ? 'Sign in' : 'Create an account'}
|
{authMode === 'login' ? 'Sign in' : 'Create an account'}
|
||||||
</h1>
|
</h1>
|
||||||
<form onSubmit={submitAuth} className="mt-6 space-y-4">
|
<form onSubmit={submitAuth} className="mt-6 space-y-4">
|
||||||
<label className="block text-sm font-medium text-slate-700">
|
<label className="block text-sm">
|
||||||
Email
|
Email
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
@@ -149,10 +151,10 @@ export default function AuthPage() {
|
|||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
value={authForm.email}
|
value={authForm.email}
|
||||||
onChange={(e) => setAuthForm((f) => ({ ...f, email: e.target.value }))}
|
onChange={(e) => setAuthForm((f) => ({ ...f, email: e.target.value }))}
|
||||||
className="mt-1 block w-full rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
className="mt-1 block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-[var(--r-text)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="block text-sm font-medium text-slate-700">
|
<label className="block text-sm font-medium text-[var(--r-text)]">
|
||||||
Password
|
Password
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
@@ -161,14 +163,14 @@ export default function AuthPage() {
|
|||||||
autoComplete={authMode === 'login' ? 'current-password' : 'new-password'}
|
autoComplete={authMode === 'login' ? 'current-password' : 'new-password'}
|
||||||
value={authForm.password}
|
value={authForm.password}
|
||||||
onChange={(e) => setAuthForm((f) => ({ ...f, password: e.target.value }))}
|
onChange={(e) => setAuthForm((f) => ({ ...f, password: e.target.value }))}
|
||||||
className="mt-1 block w-full rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
className="mt-1 block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-[var(--r-text)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{authError && <p className="text-sm text-rose-600">{authError}</p>}
|
{authError && <p className="text-sm text-[var(--r-danger,#b42318)]">{authError}</p>}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={authBusy}
|
disabled={authBusy}
|
||||||
className="w-full rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:bg-slate-300"
|
className="w-full rounded-full bg-[var(--r-accent)] px-4 py-2.5 text-sm font-medium text-[var(--r-on)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{authBusy ? 'Please wait…' : authMode === 'login' ? 'Sign in' : 'Create account'}
|
{authBusy ? 'Please wait…' : authMode === 'login' ? 'Sign in' : 'Create account'}
|
||||||
</button>
|
</button>
|
||||||
@@ -179,7 +181,7 @@ export default function AuthPage() {
|
|||||||
setAuthMode((m) => (m === 'login' ? 'register' : 'login'));
|
setAuthMode((m) => (m === 'login' ? 'register' : 'login'));
|
||||||
setAuthError('');
|
setAuthError('');
|
||||||
}}
|
}}
|
||||||
className="mt-4 w-full text-center text-sm text-slate-500 underline hover:text-slate-700"
|
className="mt-4 w-full text-center text-sm text-[var(--r-sub)] underline hover:text-[var(--r-text)]"
|
||||||
>
|
>
|
||||||
{authMode === 'login' ? "Need an account? Sign up" : 'Already have an account? Sign in'}
|
{authMode === 'login' ? "Need an account? Sign up" : 'Already have an account? Sign in'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// The Export stage: take the finished study out as a file, or share it read-only.
|
||||||
|
import { useApp } from '../context/AppContext.js';
|
||||||
|
import { useShareLink } from '../hooks/useShareLink.js';
|
||||||
|
import StudioShell from '../components/study/StudioShell.jsx';
|
||||||
|
import { exportProjectDocx, exportProjectHtml, exportProjectMarkdown, printProjectPdf } from '../utils/projectExport.js';
|
||||||
|
|
||||||
|
const card = 'rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5';
|
||||||
|
const btn = 'rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5 disabled:opacity-50';
|
||||||
|
|
||||||
|
function Row({ title, note, button, onClick }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-4 border-b border-[var(--r-line)] py-3 last:border-0">
|
||||||
|
<div className="min-w-0"><p className="text-sm font-medium">{title}</p><p className="text-xs text-[var(--r-sub)]">{note}</p></div>
|
||||||
|
<button type="button" onClick={onClick} className={`${btn} shrink-0`}>{button}</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ExportPage() {
|
||||||
|
const { project, allChunks, setCurrentPage } = useApp();
|
||||||
|
const share = useShareLink(project?.id);
|
||||||
|
const filled = allChunks.filter((c) => (c.finalScript ?? '').trim()).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StudioShell stage="export">
|
||||||
|
<main className="mx-auto max-w-2xl space-y-5 px-4 py-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-medium">Export</h1>
|
||||||
|
<p className="mt-1 text-sm text-[var(--r-sub)]">{allChunks.length} chunk{allChunks.length === 1 ? '' : 's'} · {filled} script{filled === 1 ? '' : 's'} written</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className={card} aria-label="Files">
|
||||||
|
<h2 className="mb-1 text-sm font-medium">Files</h2>
|
||||||
|
<Row title="Word document" note="Notes, words and scripts, formatted for editing." button="Export DOCX" onClick={() => exportProjectDocx(project)} />
|
||||||
|
<Row title="PDF" note="Opens a print-friendly page. Choose “Save as PDF” in the print dialog." button="Print / Save PDF" onClick={() => printProjectPdf(project)} />
|
||||||
|
<Row title="Markdown" note="For Obsidian, Notion, or any Markdown-based notes app." button="Export Markdown" onClick={() => exportProjectMarkdown(project)} />
|
||||||
|
<Row title="Web page" note="A single HTML file you can open anywhere." button="Export HTML" onClick={() => exportProjectHtml(project)} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={card} aria-label="Sharing">
|
||||||
|
<h2 className="text-sm font-medium">Read-only share link</h2>
|
||||||
|
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">Anyone with the link can view (not edit) this study, with no account needed. Good for a co-teacher or a listener following along.</p>
|
||||||
|
{share.shareToken ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input readOnly value={share.shareUrl} 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={share.copy} className="shrink-0 rounded-lg bg-[var(--r-accent)] px-4 py-2 text-xs font-medium text-[var(--r-on)]">{share.shareCopied ? 'Copied!' : 'Copy'}</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={share.revoke} disabled={share.shareBusy} className="text-xs text-[var(--r-danger,#b42318)] hover:underline">{share.shareBusy ? 'Revoking…' : 'Revoke link'}</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button type="button" onClick={share.enable} disabled={share.shareBusy} className={btn}>{share.shareBusy ? 'Enabling…' : 'Enable sharing'}</button>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={card} aria-label="Pronunciation">
|
||||||
|
<Row title="Pronunciation guide" note="Every Greek and Hebrew word in this study, with transliterations." button="Open guide" onClick={() => setCurrentPage('pronunciation')} />
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</StudioShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,27 +1,35 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useApp } from '../context/AppContext.js';
|
import { useApp } from '../context/AppContext.js';
|
||||||
import AuthStatus from '../components/AuthStatus.jsx';
|
import PageShell from '../components/study/PageShell.jsx';
|
||||||
|
import AudioPlayer from '../components/AudioPlayer.jsx';
|
||||||
import {
|
import {
|
||||||
bookOptions,
|
bookOptions,
|
||||||
CHAPTER_COUNTS,
|
|
||||||
formatChunkReference,
|
formatChunkReference,
|
||||||
formatRelativeDate,
|
formatRelativeDate,
|
||||||
loadProjectIndex,
|
loadProjectIndex,
|
||||||
} from '../utils/bibleUtils.jsx';
|
} from '../utils/bibleUtils.jsx';
|
||||||
import { loadProjectById } from '../utils/projectStorage.js';
|
import { loadProjectById } from '../utils/projectStorage.js';
|
||||||
|
import { compactPassages } from '../utils/passageSummary.js';
|
||||||
|
import { getPlan, getPlanSchedule, formatDayReadings, nextUnreadDayIndex, computeStreak } from '../utils/readingPlans.js';
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const {
|
const {
|
||||||
autoRestoredCount,
|
autoRestoredCount,
|
||||||
staleLocalProjects,
|
staleLocalProjects,
|
||||||
projectIndex,
|
projectIndex,
|
||||||
readingPlan,
|
planProgress,
|
||||||
createReadingPlan,
|
readingActivity,
|
||||||
clearReadingPlan,
|
openReadingPlans,
|
||||||
|
openPlanReading,
|
||||||
|
studyReadingPlanDay,
|
||||||
|
studyBuildBusy,
|
||||||
|
readingPlanSyncStatus,
|
||||||
|
readingPlanSyncError,
|
||||||
|
retryReadingPlanSync,
|
||||||
audioBook, setAudioBook,
|
audioBook, setAudioBook,
|
||||||
audioNarrator, setAudioNarrator,
|
audioNarrator, setAudioNarrator,
|
||||||
audioState,
|
audioState,
|
||||||
openBibleReader,
|
audioProgress,
|
||||||
openImportProject,
|
openImportProject,
|
||||||
openNewProject,
|
openNewProject,
|
||||||
pullLatestFromServer,
|
pullLatestFromServer,
|
||||||
@@ -31,6 +39,9 @@ export default function HomePage() {
|
|||||||
handlePlayBookAudio,
|
handlePlayBookAudio,
|
||||||
handleStopBookAudio,
|
handleStopBookAudio,
|
||||||
handleToggleBookAudioPause,
|
handleToggleBookAudioPause,
|
||||||
|
handleSkipBookChapter,
|
||||||
|
handleSeekAudio,
|
||||||
|
handleSeekAudioTo,
|
||||||
} = useApp();
|
} = useApp();
|
||||||
const [homeSearch, setHomeSearch] = useState('');
|
const [homeSearch, setHomeSearch] = useState('');
|
||||||
const [homeSort, setHomeSort] = useState(() => {
|
const [homeSort, setHomeSort] = useState(() => {
|
||||||
@@ -78,434 +89,241 @@ export default function HomePage() {
|
|||||||
}, 300);
|
}, 300);
|
||||||
}, [homeSearch]);
|
}, [homeSearch]);
|
||||||
|
|
||||||
return (
|
const allTags = Array.from(new Set(projectIndex.flatMap((entry) => entry.tags ?? []))).sort((a, b) => a.localeCompare(b));
|
||||||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
const visible = projectIndex
|
||||||
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm">
|
|
||||||
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-3 px-4 py-4 sm:px-6 lg:px-8">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p>
|
|
||||||
<h1 className="mt-1 text-2xl font-semibold">My Studies</h1>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={openBibleReader}
|
|
||||||
className="rounded-xl border border-slate-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-700"
|
|
||||||
>
|
|
||||||
📖 Read Bible
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={openImportProject}
|
|
||||||
className="rounded-xl border border-slate-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-700"
|
|
||||||
>
|
|
||||||
<span className="hidden sm:inline">📥 Import Session List</span>
|
|
||||||
<span className="sm:hidden">📥 Import</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={openNewProject}
|
|
||||||
className="rounded-xl bg-slate-700 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-600"
|
|
||||||
>
|
|
||||||
+ New Project
|
|
||||||
</button>
|
|
||||||
<AuthStatus />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<main className="mx-auto max-w-7xl px-4 py-4 sm:py-8 sm:px-6 lg:px-8">
|
|
||||||
{autoRestoredCount !== null && (
|
|
||||||
<div className="mb-6 rounded-2xl border border-emerald-200 bg-emerald-50 p-4">
|
|
||||||
<p className="text-sm font-semibold text-emerald-800">
|
|
||||||
📥 Synced {autoRestoredCount} project{autoRestoredCount > 1 ? 's' : ''} from another device.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{staleLocalProjects.length > 0 && (
|
|
||||||
<div className="mb-6 rounded-2xl border border-amber-200 bg-amber-50 p-4">
|
|
||||||
<p className="mb-3 text-sm font-semibold text-amber-800">
|
|
||||||
☁️ {staleLocalProjects.length} project{staleLocalProjects.length > 1 ? 's have' : ' has'} a newer version on the server:
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{staleLocalProjects.map((entry) => (
|
|
||||||
<button
|
|
||||||
key={entry.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => pullLatestFromServer(entry.id)}
|
|
||||||
className="rounded-xl bg-amber-700 px-4 py-2 text-sm font-semibold text-white transition hover:bg-amber-600"
|
|
||||||
>
|
|
||||||
Pull latest "{entry.title}"
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{projectIndex.length === 0 ? (
|
|
||||||
<div className="mx-auto max-w-xl rounded-3xl border border-dashed border-slate-300 bg-white p-10 text-center shadow-panel">
|
|
||||||
<p className="text-lg font-semibold text-slate-700">No projects yet</p>
|
|
||||||
<p className="mt-2 text-sm text-slate-500">Start a new Bible study to get going.</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={openNewProject}
|
|
||||||
className="mt-6 rounded-xl bg-slate-900 px-6 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-800"
|
|
||||||
>
|
|
||||||
+ New Project
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={homeSearch}
|
|
||||||
onChange={(e) => setHomeSearch(e.target.value)}
|
|
||||||
placeholder="Search projects by title or passage…"
|
|
||||||
className="w-full max-w-sm rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
|
||||||
/>
|
|
||||||
<label className="text-sm text-slate-600">
|
|
||||||
Sort by{' '}
|
|
||||||
<select
|
|
||||||
value={homeSort}
|
|
||||||
onChange={(e) => setHomeSort(e.target.value)}
|
|
||||||
className="ml-1 rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
|
||||||
>
|
|
||||||
<option value="recent">Last edited</option>
|
|
||||||
<option value="title">Title</option>
|
|
||||||
<option value="passage">Passage</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
{(() => {
|
|
||||||
const allTags = Array.from(
|
|
||||||
new Set(projectIndex.flatMap((entry) => entry.tags ?? [])),
|
|
||||||
).sort((a, b) => a.localeCompare(b));
|
|
||||||
if (allTags.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<label className="text-sm text-slate-600">
|
|
||||||
Tag{' '}
|
|
||||||
<select
|
|
||||||
value={homeTagFilter}
|
|
||||||
onChange={(e) => setHomeTagFilter(e.target.value)}
|
|
||||||
className="ml-1 rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
|
||||||
>
|
|
||||||
<option value="">All tags</option>
|
|
||||||
{allTags.map((tag) => (
|
|
||||||
<option key={tag} value={tag}>{tag}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
{/* Full-text note search results */}
|
|
||||||
{homeFullTextResults !== null && (
|
|
||||||
<div className="mb-4 rounded-3xl border border-slate-200 bg-white p-5 shadow-panel">
|
|
||||||
<p className="mb-3 text-sm font-semibold text-slate-700">
|
|
||||||
{homeFullTextResults.length === 0
|
|
||||||
? 'No notes match your search.'
|
|
||||||
: `Notes matching "${homeSearch.trim()}" — ${homeFullTextResults.reduce((n, r) => n + r.matches.length, 0)} result${homeFullTextResults.reduce((n, r) => n + r.matches.length, 0) !== 1 ? 's' : ''} across ${homeFullTextResults.length} project${homeFullTextResults.length !== 1 ? 's' : ''}`}
|
|
||||||
</p>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{homeFullTextResults.map(({ projectId, projectTitle, matches }) => (
|
|
||||||
<div key={projectId}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => resumeProject(projectId)}
|
|
||||||
className="mb-1.5 text-sm font-semibold text-violet-700 hover:underline"
|
|
||||||
>
|
|
||||||
{projectTitle} →
|
|
||||||
</button>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{matches.map(({ chunkId, ref, field, snippet }) => (
|
|
||||||
<div key={chunkId} className="rounded-2xl bg-slate-50 px-3 py-2">
|
|
||||||
<span className="text-xs font-semibold uppercase tracking-wide text-slate-400">{ref} · {field}</span>
|
|
||||||
<p className="mt-0.5 text-sm text-slate-700 leading-snug">{snippet}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Reading plan card */}
|
|
||||||
<ReadingPlanCard readingPlan={readingPlan} createReadingPlan={createReadingPlan} clearReadingPlan={clearReadingPlan} openBibleReader={openBibleReader} />
|
|
||||||
|
|
||||||
<div className="mb-4 flex flex-col gap-3 rounded-3xl border border-slate-200 bg-white p-6 shadow-panel sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="text-base font-semibold text-slate-900">Listen to BSB Audio</h3>
|
|
||||||
<p className="mt-1 text-sm text-slate-600">
|
|
||||||
{audioState.status === 'idle' || audioState.status === 'error'
|
|
||||||
? 'Play a full book of the Berean Standard Bible.'
|
|
||||||
: `Playing ${bookOptions.find((b) => b.abbrev === audioBook)?.name} — chapter ${audioState.chapter} of ${audioState.total}`}
|
|
||||||
</p>
|
|
||||||
{audioState.status === 'error' && (
|
|
||||||
<p className="mt-1 text-sm text-rose-600">Couldn't load audio for this book/narrator.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<select
|
|
||||||
value={audioBook}
|
|
||||||
onChange={(e) => setAudioBook(e.target.value)}
|
|
||||||
disabled={audioState.status === 'playing' || audioState.status === 'paused'}
|
|
||||||
className="rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{bookOptions.map((book) => (
|
|
||||||
<option key={book.abbrev} value={book.abbrev}>{book.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
value={audioNarrator}
|
|
||||||
onChange={(e) => setAudioNarrator(e.target.value)}
|
|
||||||
disabled={audioState.status === 'playing' || audioState.status === 'paused'}
|
|
||||||
className="rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 disabled:opacity-60"
|
|
||||||
>
|
|
||||||
<option value="david">David</option>
|
|
||||||
<option value="hays">Hays</option>
|
|
||||||
<option value="souer">Souer</option>
|
|
||||||
</select>
|
|
||||||
{audioState.status === 'playing' || audioState.status === 'paused' ? (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleToggleBookAudioPause}
|
|
||||||
className="rounded-xl bg-sky-600 px-3 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-sky-500"
|
|
||||||
>
|
|
||||||
{audioState.status === 'paused' ? 'Resume' : 'Pause'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleStopBookAudio}
|
|
||||||
className="rounded-xl border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50"
|
|
||||||
>
|
|
||||||
Stop
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handlePlayBookAudio}
|
|
||||||
className="rounded-xl bg-sky-600 px-3 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-sky-500"
|
|
||||||
>
|
|
||||||
Play book
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{projectIndex
|
|
||||||
.slice()
|
.slice()
|
||||||
.filter((entry) => {
|
.filter((entry) => {
|
||||||
if (homeTagFilter && !(entry.tags ?? []).includes(homeTagFilter)) return false;
|
if (homeTagFilter && !(entry.tags ?? []).includes(homeTagFilter)) return false;
|
||||||
const q = homeSearch.trim().toLowerCase();
|
const q = homeSearch.trim().toLowerCase();
|
||||||
if (!q) return true;
|
if (!q) return true;
|
||||||
return entry.title?.toLowerCase().includes(q)
|
return entry.title?.toLowerCase().includes(q) || entry.chapterSummary?.toLowerCase().includes(q);
|
||||||
|| entry.chapterSummary?.toLowerCase().includes(q);
|
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
if (homeSort === 'title') return (a.title ?? '').localeCompare(b.title ?? '');
|
if (homeSort === 'title') return (a.title ?? '').localeCompare(b.title ?? '');
|
||||||
if (homeSort === 'passage') return (a.chapterSummary ?? '').localeCompare(b.chapterSummary ?? '');
|
if (homeSort === 'passage') return (a.chapterSummary ?? '').localeCompare(b.chapterSummary ?? '');
|
||||||
return (b.lastEdited ?? 0) - (a.lastEdited ?? 0);
|
return (b.lastEdited ?? 0) - (a.lastEdited ?? 0);
|
||||||
})
|
});
|
||||||
.map((entry) => (
|
const fieldCls = 'rounded-lg border border-[var(--r-line)] bg-[var(--r-bar)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
|
||||||
<div
|
const linkBtn = 'rounded-lg px-3 py-1.5 text-sm text-[var(--r-sub)] hover:bg-black/5';
|
||||||
key={entry.id}
|
const notice = 'mb-4 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-3 text-sm';
|
||||||
className="flex flex-col gap-4 rounded-3xl border border-slate-200 bg-white p-6 shadow-panel"
|
|
||||||
|
return (
|
||||||
|
<PageShell
|
||||||
|
title="My Studies"
|
||||||
|
active="studies"
|
||||||
|
actions={(
|
||||||
|
<>
|
||||||
|
<button type="button" onClick={openImportProject} className={linkBtn} title="Set up a whole series from a session list (.docx)">Import Session List</button>
|
||||||
|
<button type="button" onClick={openNewProject} className="rounded-full bg-[var(--r-accent)] px-4 py-1.5 text-sm font-medium text-[var(--r-on)] hover:opacity-90">+ New Project</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
|
<main className="mx-auto grid max-w-6xl gap-6 px-4 py-6 lg:grid-cols-[minmax(0,1fr)_21rem]">
|
||||||
|
<section className="min-w-0">
|
||||||
|
{autoRestoredCount !== null && (
|
||||||
|
<div className={notice}>Synced {autoRestoredCount} project{autoRestoredCount > 1 ? 's' : ''} from another device.</div>
|
||||||
|
)}
|
||||||
|
{staleLocalProjects.length > 0 && (
|
||||||
|
<div className={notice}>
|
||||||
|
<p className="mb-2">{staleLocalProjects.length} project{staleLocalProjects.length > 1 ? 's have' : ' has'} a newer version on the server:</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{staleLocalProjects.map((entry) => (
|
||||||
|
<button key={entry.id} type="button" onClick={() => pullLatestFromServer(entry.id)}
|
||||||
|
className="rounded-full border border-[var(--r-accent)] px-3 py-1 text-xs text-[var(--r-accent)] hover:bg-black/5">Pull latest “{entry.title}”</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{readingPlanSyncStatus === 'error' && (
|
||||||
|
<div className={`${notice} flex flex-wrap items-center justify-between gap-3`}>
|
||||||
|
<span className="text-[var(--r-danger,#b42318)]">Couldn’t sync your reading plans to the server. Changes are only saved on this device until this is fixed.</span>
|
||||||
|
<button type="button" onClick={retryReadingPlanSync} className="shrink-0 rounded-lg border border-red-300 px-3 py-1 text-xs text-[var(--r-danger,#b42318)] hover:bg-red-50">Retry</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{projectIndex.length === 0 ? (
|
||||||
|
<div className="mx-auto max-w-xl rounded-2xl border border-dashed border-[var(--r-line)] p-10 text-center">
|
||||||
|
<p className="text-lg font-medium">No projects yet</p>
|
||||||
|
<p className="mt-2 text-sm text-[var(--r-sub)]">Start a new Bible study to get going.</p>
|
||||||
|
<button type="button" onClick={openNewProject} className="mt-6 rounded-full bg-[var(--r-accent)] px-6 py-2.5 text-sm font-medium text-[var(--r-on)] hover:opacity-90">+ New Project</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||||
|
<input type="text" value={homeSearch} onChange={(e) => setHomeSearch(e.target.value)} placeholder="Search projects by title or passage…"
|
||||||
|
className={`${fieldCls} w-full max-w-sm`} />
|
||||||
|
<label className="text-sm text-[var(--r-sub)]">Sort by{' '}
|
||||||
|
<select value={homeSort} onChange={(e) => setHomeSort(e.target.value)} className={`${fieldCls} ml-1`}>
|
||||||
|
<option value="recent">Last edited</option>
|
||||||
|
<option value="title">Title</option>
|
||||||
|
<option value="passage">Passage</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{allTags.length > 0 && (
|
||||||
|
<label className="text-sm text-[var(--r-sub)]">Tag{' '}
|
||||||
|
<select value={homeTagFilter} onChange={(e) => setHomeTagFilter(e.target.value)} className={`${fieldCls} ml-1`}>
|
||||||
|
<option value="">All tags</option>
|
||||||
|
{allTags.map((tag) => <option key={tag} value={tag}>{tag}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Full-text note search results */}
|
||||||
|
{homeFullTextResults !== null && (
|
||||||
|
<div className="mb-4 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5">
|
||||||
|
<p className="mb-3 text-sm font-medium">
|
||||||
|
{homeFullTextResults.length === 0
|
||||||
|
? 'No notes match your search.'
|
||||||
|
: `Notes matching "${homeSearch.trim()}" — ${homeFullTextResults.reduce((n, r) => n + r.matches.length, 0)} result${homeFullTextResults.reduce((n, r) => n + r.matches.length, 0) !== 1 ? 's' : ''}`}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{homeFullTextResults.map(({ projectId, projectTitle, matches }) => (
|
||||||
|
<div key={projectId}>
|
||||||
|
<button type="button" onClick={() => resumeProject(projectId)} className="mb-1.5 text-sm font-medium text-[var(--r-accent)] hover:underline">{projectTitle} →</button>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{matches.map(({ chunkId, ref, field, snippet }) => (
|
||||||
|
<div key={chunkId} className="rounded-xl bg-[var(--r-bg)] px-3 py-2">
|
||||||
|
<span className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">{ref} · {field}</span>
|
||||||
|
<p className="mt-0.5 text-sm leading-snug">{snippet}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{visible.map((entry) => (
|
||||||
|
<div key={entry.id} className="flex flex-col gap-3 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
{renamingId === entry.id ? (
|
{renamingId === entry.id ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input type="text" autoFocus value={renameValue} onChange={(e) => setRenameValue(e.target.value)}
|
||||||
type="text"
|
|
||||||
autoFocus
|
|
||||||
value={renameValue}
|
|
||||||
onChange={(e) => setRenameValue(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') { e.preventDefault(); const t = renameValue.trim(); if (t) renameProjectInStorage(entry.id, t); setRenamingId(null); }
|
||||||
e.preventDefault();
|
else if (e.key === 'Escape') setRenamingId(null);
|
||||||
const trimmed = renameValue.trim();
|
|
||||||
if (trimmed) renameProjectInStorage(entry.id, trimmed);
|
|
||||||
setRenamingId(null);
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
setRenamingId(null);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
className="flex-1 rounded-lg border border-slate-300 px-2 py-1 text-base font-semibold text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
className={`${fieldCls} flex-1`} />
|
||||||
/>
|
<button type="button" onClick={() => { const t = renameValue.trim(); if (t) renameProjectInStorage(entry.id, t); setRenamingId(null); }} className="text-sm text-[var(--r-accent)] hover:underline">Save</button>
|
||||||
<button
|
<button type="button" onClick={() => setRenamingId(null)} className="text-sm text-[var(--r-sub)]">×</button>
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const trimmed = renameValue.trim();
|
|
||||||
if (trimmed) renameProjectInStorage(entry.id, trimmed);
|
|
||||||
setRenamingId(null);
|
|
||||||
}}
|
|
||||||
className="text-sm font-semibold text-emerald-600 hover:text-emerald-700"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setRenamingId(null)}
|
|
||||||
className="text-sm text-slate-400 hover:text-slate-600"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<h2 className="text-base font-semibold text-slate-900">{entry.title}</h2>
|
<h2 className="text-base font-medium">{entry.title}</h2>
|
||||||
<button
|
<button type="button" onClick={() => { setRenamingId(entry.id); setRenameValue(entry.title ?? ''); }} className="shrink-0 text-xs text-[var(--r-sub)] hover:text-[var(--r-text)]" title="Rename project">✎ Rename</button>
|
||||||
type="button"
|
|
||||||
onClick={() => { setRenamingId(entry.id); setRenameValue(entry.title ?? ''); }}
|
|
||||||
className="shrink-0 text-xs text-slate-400 hover:text-slate-600"
|
|
||||||
title="Rename project"
|
|
||||||
>
|
|
||||||
✎ Rename
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{entry.chapterSummary && (
|
{entry.chapterSummary && <p className="mt-1 text-sm text-[var(--r-sub)]" title={entry.chapterSummary}>{compactPassages(entry.chapterSummary)}</p>}
|
||||||
<p className="mt-1 text-sm text-slate-500">{entry.chapterSummary}</p>
|
|
||||||
)}
|
|
||||||
{entry.tags?.length > 0 && (
|
{entry.tags?.length > 0 && (
|
||||||
<div className="mt-2 flex flex-wrap gap-1">
|
// A few tags and a "+N" for the rest (hover for all; the Tag filter above finds any of them)
|
||||||
{entry.tags.map((tag) => (
|
<div className="mt-2 flex flex-wrap gap-1" title={entry.tags.length > 3 ? entry.tags.join(', ') : undefined}>
|
||||||
<span key={tag} className="rounded-full bg-indigo-100 px-2 py-0.5 text-xs font-semibold text-indigo-700">
|
{entry.tags.slice(0, 3).map((tag) => <span key={tag} className="rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[11px] text-[var(--r-sub)]">{tag}</span>)}
|
||||||
{tag}
|
{entry.tags.length > 3 && <span className="rounded-full px-1.5 py-0.5 text-[11px] text-[var(--r-sub)]">+{entry.tags.length - 3} more</span>}
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="mt-1 text-xs text-slate-400">{formatRelativeDate(entry.lastEdited)}</p>
|
<p className="mt-1 text-xs text-[var(--r-sub)]">{formatRelativeDate(entry.lastEdited)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button type="button" onClick={() => resumeProject(entry.id)} className="flex-1 rounded-xl bg-[var(--r-text)] px-4 py-2 text-sm font-medium text-[var(--r-bg)] hover:opacity-90">Resume</button>
|
||||||
type="button"
|
<button type="button" onClick={() => deleteProject(entry.id)} className="rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm text-[var(--r-danger,#b42318)] hover:bg-black/5">Delete</button>
|
||||||
onClick={() => resumeProject(entry.id)}
|
|
||||||
className="flex-1 rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-800"
|
|
||||||
>
|
|
||||||
Resume
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => deleteProject(entry.id)}
|
|
||||||
className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-2 text-sm font-semibold text-rose-700 transition hover:bg-rose-100"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside className="space-y-4">
|
||||||
|
<TodayCard planProgress={planProgress} readingActivity={readingActivity} openReadingPlans={openReadingPlans}
|
||||||
|
openPlanReading={openPlanReading} studyReadingPlanDay={studyReadingPlanDay} studyBuildBusy={studyBuildBusy} />
|
||||||
|
|
||||||
|
{/* BSB audio player — always shown, so it's easy to find */}
|
||||||
|
<AudioPlayer
|
||||||
|
title="Listen to BSB Audio"
|
||||||
|
subtitle={audioState.status === 'idle' || audioState.status === 'error'
|
||||||
|
? 'Play a full book of the Berean Standard Bible.'
|
||||||
|
: `Playing ${bookOptions.find((b) => b.abbrev === audioBook)?.name} — chapter ${audioState.chapter} of ${audioState.total}`}
|
||||||
|
errorMessage="Couldn't load audio for this book/narrator."
|
||||||
|
status={audioState.status}
|
||||||
|
currentTime={audioProgress.currentTime}
|
||||||
|
duration={audioProgress.duration}
|
||||||
|
onPlay={handlePlayBookAudio}
|
||||||
|
onTogglePause={handleToggleBookAudioPause}
|
||||||
|
onSeekBack={() => handleSeekAudio(-10)}
|
||||||
|
onSeekForward={() => handleSeekAudio(10)}
|
||||||
|
onSeekTo={handleSeekAudioTo}
|
||||||
|
onNext={handleSkipBookChapter}
|
||||||
|
nextDisabled={audioState.chapter >= audioState.total}
|
||||||
|
onStop={handleStopBookAudio}
|
||||||
|
controls={(
|
||||||
|
<>
|
||||||
|
<select value={audioBook} onChange={(e) => setAudioBook(e.target.value)} disabled={audioState.status === 'playing' || audioState.status === 'paused'} className={fieldCls}>
|
||||||
|
{bookOptions.map((book) => <option key={book.abbrev} value={book.abbrev}>{book.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<select value={audioNarrator} onChange={(e) => setAudioNarrator(e.target.value)} disabled={audioState.status === 'playing' || audioState.status === 'paused'} className={fieldCls}>
|
||||||
|
<option value="david">David</option>
|
||||||
|
<option value="hays">Hays</option>
|
||||||
|
<option value="souer">Souer</option>
|
||||||
|
</select>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
</main>
|
</main>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Today's reading across your active plans, each opening straight into the Reader.
|
||||||
|
function TodayCard({ planProgress, readingActivity, openReadingPlans, openPlanReading, studyReadingPlanDay, studyBuildBusy }) {
|
||||||
|
const active = planProgress.filter((p) => !p.completedAt);
|
||||||
|
const streak = computeStreak(readingActivity);
|
||||||
|
const streakText = streak.current > 0 && <span className="text-xs text-[var(--r-accent)]">{streak.current} day{streak.current !== 1 ? 's' : ''} in a row</span>;
|
||||||
|
|
||||||
|
if (active.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5">
|
||||||
|
<div className="flex items-center justify-between gap-2"><p className="text-sm font-medium">Reading Plans</p>{streakText}</div>
|
||||||
|
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">Read through the Bible, the Gospels, Psalms, and more — one day at a time.</p>
|
||||||
|
<button type="button" onClick={openReadingPlans} className="rounded-full border border-[var(--r-line)] px-4 py-1.5 text-sm hover:bg-black/5">Browse plans</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReadingPlanCard({ readingPlan, createReadingPlan, clearReadingPlan, openBibleReader }) {
|
const rows = active.map((progress) => {
|
||||||
const [showForm, setShowForm] = useState(false);
|
const plan = getPlan(progress.planId);
|
||||||
const [planBook, setPlanBook] = useState(bookOptions[0].abbrev);
|
if (!plan) return null;
|
||||||
const [planWeeks, setPlanWeeks] = useState(4);
|
const dayIndex = nextUnreadDayIndex(plan, progress.completedDays);
|
||||||
|
const refs = dayIndex != null ? getPlanSchedule(progress.planId)[dayIndex] : null;
|
||||||
|
return { progress, plan, dayIndex, refs, pct: plan.days > 0 ? Math.round((progress.completedDays.length / plan.days) * 100) : 0 };
|
||||||
|
}).filter(Boolean);
|
||||||
|
|
||||||
if (readingPlan) {
|
|
||||||
const pct = readingPlan.totalChapters > 0
|
|
||||||
? Math.round((readingPlan.chaptersRead.length / readingPlan.totalChapters) * 100)
|
|
||||||
: 0;
|
|
||||||
const daysLeft = Math.max(0, Math.ceil((readingPlan.targetDate - Date.now()) / 86400000));
|
|
||||||
const chapLeft = readingPlan.totalChapters - readingPlan.chaptersRead.length;
|
|
||||||
const paceNeeded = daysLeft > 0 ? (chapLeft / daysLeft).toFixed(1) : '—';
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-4 rounded-3xl border border-emerald-200 bg-emerald-50 p-5 shadow-panel">
|
<div className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="mb-3 flex items-center justify-between gap-2">
|
||||||
<div>
|
<h2 className="text-sm font-medium">Today’s Reading</h2>
|
||||||
<p className="text-xs font-semibold uppercase tracking-widest text-emerald-600">Reading Plan</p>
|
<span className="flex items-center gap-3">{streakText}<button type="button" onClick={openReadingPlans} className="text-xs text-[var(--r-accent)] hover:underline">All plans →</button></span>
|
||||||
<h3 className="mt-1 text-base font-semibold text-slate-900">{readingPlan.bookName}</h3>
|
|
||||||
<p className="mt-0.5 text-sm text-slate-600">
|
|
||||||
{readingPlan.chaptersRead.length} / {readingPlan.totalChapters} chapters · {daysLeft} day{daysLeft !== 1 ? 's' : ''} left · {paceNeeded} ch/day needed
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="space-y-3">
|
||||||
<button
|
{rows.map(({ progress, plan, dayIndex, refs, pct }) => (
|
||||||
type="button"
|
<div key={progress.id}>
|
||||||
onClick={openBibleReader}
|
<p className="truncate text-sm font-medium">{plan.icon ? `${plan.icon} ` : ''}{plan.title}</p>
|
||||||
className="rounded-xl bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-500"
|
<p className="mb-1.5 truncate font-serif text-sm text-[var(--r-sub)]">{refs ? formatDayReadings(refs) : `${progress.completedDays.length} / ${plan.days} days`}</p>
|
||||||
>
|
<div className="mb-2 h-1 overflow-hidden rounded-full bg-[var(--r-line)]"><div className="h-full bg-[var(--r-accent)]" style={{ width: `${pct}%` }} /></div>
|
||||||
Read
|
{refs && (
|
||||||
</button>
|
<div className="flex gap-2">
|
||||||
<button
|
<button type="button" onClick={() => openPlanReading(progress.id, dayIndex)} className="rounded-full bg-[var(--r-accent)] px-4 py-1.5 text-xs font-medium text-[var(--r-on)] hover:opacity-90">Continue reading</button>
|
||||||
type="button"
|
<button type="button" disabled={studyBuildBusy} onClick={() => studyReadingPlanDay(refs, `${plan.title} — ${formatDayReadings(refs)}`)}
|
||||||
onClick={() => { if (window.confirm('Clear reading plan?')) clearReadingPlan(); }}
|
className="rounded-full border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50" title="Start a study project from this passage">Study</button>
|
||||||
className="rounded-xl border border-emerald-300 px-3 py-1.5 text-sm text-emerald-700 hover:bg-emerald-100"
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="mt-3 h-2.5 w-full overflow-hidden rounded-full bg-emerald-200">
|
|
||||||
<div className="h-full rounded-full bg-emerald-500 transition-all" style={{ width: `${pct}%` }} />
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-right text-xs text-emerald-700">{pct}%</p>
|
|
||||||
{readingPlan.chaptersRead.length > 0 && (
|
|
||||||
<p className="mt-1 text-xs text-slate-500">
|
|
||||||
Read: ch. {readingPlan.chaptersRead.slice(0, 12).join(', ')}{readingPlan.chaptersRead.length > 12 ? '…' : ''}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mb-4 rounded-3xl border border-slate-200 bg-white p-5 shadow-panel">
|
|
||||||
{showForm ? (
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<span className="text-sm font-semibold text-slate-700">Read</span>
|
|
||||||
<select
|
|
||||||
value={planBook}
|
|
||||||
onChange={(e) => setPlanBook(e.target.value)}
|
|
||||||
className="rounded-xl border border-slate-300 bg-slate-50 px-2 py-1.5 text-sm text-slate-900 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
|
||||||
>
|
|
||||||
{bookOptions.map((b) => (
|
|
||||||
<option key={b.abbrev} value={b.abbrev}>{b.name} ({CHAPTER_COUNTS[b.abbrev] ?? '?'} ch)</option>
|
|
||||||
))}
|
))}
|
||||||
</select>
|
|
||||||
<span className="text-sm text-slate-600">in</span>
|
|
||||||
<select
|
|
||||||
value={planWeeks}
|
|
||||||
onChange={(e) => setPlanWeeks(Number(e.target.value))}
|
|
||||||
className="rounded-xl border border-slate-300 bg-slate-50 px-2 py-1.5 text-sm text-slate-900 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
|
||||||
>
|
|
||||||
{[1,2,3,4,6,8,12,16,26,52].map((w) => <option key={w} value={w}>{w} week{w !== 1 ? 's' : ''}</option>)}
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const book = bookOptions.find((b) => b.abbrev === planBook);
|
|
||||||
createReadingPlan(planBook, book?.name ?? planBook, planWeeks);
|
|
||||||
setShowForm(false);
|
|
||||||
}}
|
|
||||||
className="rounded-xl bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-500"
|
|
||||||
>
|
|
||||||
Start plan
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={() => setShowForm(false)} className="text-sm text-slate-400 hover:text-slate-600">Cancel</button>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-semibold text-slate-700">Reading Plan</p>
|
|
||||||
<p className="text-xs text-slate-500">Set a goal to read through a book, track chapters as you go.</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowForm(true)}
|
|
||||||
className="shrink-0 rounded-xl border border-slate-300 px-3 py-1.5 text-sm font-semibold text-slate-700 hover:bg-slate-50"
|
|
||||||
>
|
|
||||||
Set goal
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||