Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c8a632cff | ||
|
|
edc5a65d2b | ||
|
|
512c6b6889 | ||
|
|
a2185ba1dd | ||
|
|
9893883b19 | ||
|
|
31ac6b9179 |
No files matched your search
@@ -11,7 +11,7 @@ Ideas for the app that haven't been built yet, roughly in the order I'd do them.
|
|||||||
"restore a snapshot" button (snapshots already exist under `DATA_DIR/backups`).
|
"restore a snapshot" button (snapshots already exist under `DATA_DIR/backups`).
|
||||||
- [ ] **Checks on every push.** Run tests and lint on the Gitea server, plus a few real-browser tests (Playwright) for sign-in,
|
- [ ] **Checks on every push.** Run tests and lint on the Gitea server, plus a few real-browser tests (Playwright) for sign-in,
|
||||||
creating a study and syncing; so far everything is tested in simulation.
|
creating a study and syncing; so far everything is tested in simulation.
|
||||||
- [ ] **Split `server/index.js`** (now about 1,100 lines) into one file per area (auth, projects, sharing, reader data, admin).
|
- [x] **Split `server/index.js`** into one file per area (`server/routes/`: auth, account, projects, reader, sharing, admin).
|
||||||
|
|
||||||
## Teaching together
|
## Teaching together
|
||||||
- [ ] **Comments between co-teachers.** Notes on a chunk addressed to the other person, instead of editing each other's text.
|
- [ ] **Comments between co-teachers.** Notes on a chunk addressed to the other person, instead of editing each other's text.
|
||||||
|
|||||||
+22
-1039
File diff suppressed because it is too large.
Load diff
+21
-4
@@ -89,6 +89,24 @@ async function cacheTtl(headers, isText) {
|
|||||||
const cache = new Map(); // key -> { at, status, type, body }
|
const cache = new Map(); // key -> { at, status, type, body }
|
||||||
const CACHE_MAX = 1500;
|
const CACHE_MAX = 1500;
|
||||||
|
|
||||||
|
// Requests for the same thing that arrive while one is already on its way share that one call. Nothing is kept
|
||||||
|
// afterwards (that would be caching), so this spends less of the allowance without touching the API's terms.
|
||||||
|
const inflight = new Map(); // upstream path -> Promise of { status, type, body, retryAfter }
|
||||||
|
function fetchOnce(key, headers) {
|
||||||
|
let p = inflight.get(key);
|
||||||
|
if (!p) {
|
||||||
|
p = (async () => {
|
||||||
|
const upstream = await fetch(`${BASE()}${key}`, { headers, signal: AbortSignal.timeout(20_000) });
|
||||||
|
return {
|
||||||
|
status: upstream.status, ok: upstream.ok, retryAfter: upstream.headers.get('retry-after'),
|
||||||
|
type: upstream.headers.get('content-type') || 'application/json', body: Buffer.from(await upstream.arrayBuffer()),
|
||||||
|
};
|
||||||
|
})().finally(() => inflight.delete(key));
|
||||||
|
inflight.set(key, p);
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
const hits = new Map(); // `${userId}:${bucket}` -> { n, resetAt }
|
const hits = new Map(); // `${userId}:${bucket}` -> { n, resetAt }
|
||||||
function overLimit(userId, heavy) {
|
function overLimit(userId, heavy) {
|
||||||
const bucket = heavy ? 'h' : 'n';
|
const bucket = heavy ? 'h' : 'n';
|
||||||
@@ -119,12 +137,11 @@ export function registerIqBibleRoutes(app, requireAuth) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const upstream = await fetch(`${BASE()}${target.key}`, { headers, signal: AbortSignal.timeout(20_000) });
|
const upstream = await fetchOnce(target.key, headers);
|
||||||
const body = Buffer.from(await upstream.arrayBuffer());
|
const { body, type } = upstream;
|
||||||
const type = upstream.headers.get('content-type') || 'application/json';
|
|
||||||
if (upstream.status === 401) return res.status(502).json({ error: 'The IQ Bible key was rejected. Check IQBIBLE_API_KEY.' });
|
if (upstream.status === 401) return res.status(502).json({ error: 'The IQ Bible key was rejected. Check IQBIBLE_API_KEY.' });
|
||||||
if (upstream.status === 429) {
|
if (upstream.status === 429) {
|
||||||
const retry = upstream.headers.get('retry-after');
|
const retry = upstream.retryAfter;
|
||||||
if (retry) res.set('Retry-After', retry);
|
if (retry) res.set('Retry-After', retry);
|
||||||
return res.status(429).json({ error: 'The IQ Bible allowance is used up for now. Try again later.' });
|
return res.status(429).json({ error: 'The IQ Bible allowance is used up for now. Try again later.' });
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-1
@@ -1,5 +1,5 @@
|
|||||||
// @vitest-environment node
|
// @vitest-environment node
|
||||||
import { test, expect } from 'vitest';
|
import { test, expect, vi } from 'vitest';
|
||||||
import { resolveIqPath } from './iqbible.js';
|
import { resolveIqPath } from './iqbible.js';
|
||||||
|
|
||||||
test('only listed IQ Bible routes are reachable', () => {
|
test('only listed IQ Bible routes are reachable', () => {
|
||||||
@@ -32,3 +32,30 @@ test('only verse and chapter text counts as cacheable text', async () => {
|
|||||||
expect(isTextPath('/commentaries/gill/JHN/3')).toBe(false);
|
expect(isTextPath('/commentaries/gill/JHN/3')).toBe(false);
|
||||||
expect(resolveIqPath('/bibles/parallel', { v: 'eng_kjv,spa_rv' })).not.toBeNull();
|
expect(resolveIqPath('/bibles/parallel', { v: 'eng_kjv,spa_rv' })).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('identical requests made at the same moment share one upstream call, and nothing is kept afterwards', async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
process.env.IQBIBLE_API_KEY = 'k';
|
||||||
|
const realFetch = globalThis.fetch;
|
||||||
|
let upstreamCalls = 0;
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async (url, opts) => {
|
||||||
|
if (String(url).startsWith('http://localhost')) return realFetch(url, opts); // the test's own requests
|
||||||
|
upstreamCalls += 1;
|
||||||
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
|
return new Response(JSON.stringify({ data: 1 }), { status: 200, headers: { 'content-type': 'application/json' } });
|
||||||
|
}));
|
||||||
|
const { registerIqBibleRoutes } = await import('./iqbible.js');
|
||||||
|
const express = (await import('express')).default;
|
||||||
|
const app = express();
|
||||||
|
app.use((req, _res, next) => { req.session = { userId: 'u1' }; next(); });
|
||||||
|
registerIqBibleRoutes(app, (_req, _res, next) => next());
|
||||||
|
const srv = await new Promise((resolve) => { const s = app.listen(0, () => resolve(s)); });
|
||||||
|
const url = `http://localhost:${srv.address().port}/api/iq/devotionals/today`;
|
||||||
|
try {
|
||||||
|
const together = await Promise.all([realFetch(url), realFetch(url), realFetch(url)]);
|
||||||
|
expect(together.map((r) => r.status)).toEqual([200, 200, 200]);
|
||||||
|
expect(upstreamCalls).toBe(1);
|
||||||
|
await realFetch(url); // afterwards: not remembered, so it asks again
|
||||||
|
expect(upstreamCalls).toBe(2);
|
||||||
|
} finally { srv.close(); vi.unstubAllGlobals(); delete process.env.IQBIBLE_API_KEY; }
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// In-memory limiter for sign-in style endpoints: 10 FAILED attempts per 15 min, tracked per IP and per
|
||||||
|
// account (key). Successful sign-ins don't count, so a shared IP doesn't lock everyone out.
|
||||||
|
const _failures = new Map();
|
||||||
|
const FAIL_WINDOW = 15 * 60 * 1000;
|
||||||
|
const FAIL_MAX = 10;
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [k, e] of _failures) if (now > e.resetAt) _failures.delete(k);
|
||||||
|
}, 60 * 60 * 1000).unref();
|
||||||
|
export function isLimited(...keys) {
|
||||||
|
const now = Date.now();
|
||||||
|
return keys.some((k) => { const e = _failures.get(k); return e && now <= e.resetAt && e.count >= FAIL_MAX; });
|
||||||
|
}
|
||||||
|
export function recordFailure(...keys) {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const k of keys) {
|
||||||
|
const e = _failures.get(k);
|
||||||
|
if (!e || now > e.resetAt) _failures.set(k, { count: 1, resetAt: now + FAIL_WINDOW });
|
||||||
|
else e.count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function clearFailures(...keys) { keys.forEach((k) => _failures.delete(k)); }
|
||||||
|
export const TOO_MANY = { error: 'Too many attempts. Please wait 15 minutes.' };
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
anon GET /api/health 200
|
||||||
|
anon GET /api/auth/signup-mode 200
|
||||||
|
anon GET /api/auth/me 401
|
||||||
|
anon GET /api/projects 401
|
||||||
|
anon GET /api/admin/users 401
|
||||||
|
admin POST /api/auth/register 200
|
||||||
|
sam POST /api/auth/register 200
|
||||||
|
anon POST /api/auth/register 400
|
||||||
|
anon POST /api/auth/login 401
|
||||||
|
admin POST /api/auth/login 200
|
||||||
|
admin GET /api/auth/me 200
|
||||||
|
sam GET /api/auth/me 200
|
||||||
|
anon POST /api/auth/forgot 200
|
||||||
|
anon POST /api/auth/reset 400
|
||||||
|
admin PATCH /api/auth/profile 200
|
||||||
|
admin PATCH /api/auth/reminder-settings 400
|
||||||
|
admin POST /api/auth/reminder-test 400
|
||||||
|
admin POST /api/auth/change-password 401
|
||||||
|
admin POST /api/auth/mfa/setup 200
|
||||||
|
admin POST /api/auth/mfa/enable 401
|
||||||
|
anon POST /api/auth/mfa/verify 400
|
||||||
|
admin GET /api/account/export 200
|
||||||
|
admin PUT /api/projects/s1 200
|
||||||
|
admin PUT /api/projects/s1 400
|
||||||
|
admin PUT /api/projects/s1 400
|
||||||
|
admin GET /api/projects 200
|
||||||
|
admin GET /api/projects/s1 200
|
||||||
|
sam GET /api/projects/s1 404
|
||||||
|
admin PUT /api/projects/s1 409
|
||||||
|
admin GET /api/projects/s1/versions 200
|
||||||
|
admin GET /api/projects/s1/versions/999 404
|
||||||
|
admin POST /api/projects/s1/versions/999/restore 404
|
||||||
|
sam GET /api/projects/s1/versions 404
|
||||||
|
admin PUT /api/reader/ink/ROM/8 200
|
||||||
|
admin PUT /api/reader/ink/ROM/8 400
|
||||||
|
admin PUT /api/reader/ink/ROMANS/8 400
|
||||||
|
admin GET /api/reader/ink 200
|
||||||
|
admin PUT /api/prefs 200
|
||||||
|
admin GET /api/prefs 200
|
||||||
|
admin PUT /api/reader/marks 200
|
||||||
|
admin GET /api/reader/marks 200
|
||||||
|
admin PUT /api/reader/marks 400
|
||||||
|
admin PUT /api/prayers 200
|
||||||
|
admin GET /api/prayers 200
|
||||||
|
admin PUT /api/reader/history 200
|
||||||
|
admin GET /api/reader/history 200
|
||||||
|
admin PUT /api/reading-plans 200
|
||||||
|
admin PUT /api/reading-plans 400
|
||||||
|
admin GET /api/reading-plans 200
|
||||||
|
admin GET /api/iq/status 200
|
||||||
|
admin GET /api/iq/nope 404
|
||||||
|
admin GET /api/passkeys 200
|
||||||
|
admin GET /api/sessions 200
|
||||||
|
admin POST /api/auth/passkey/options 200
|
||||||
|
anon GET /api/bible/nope 404
|
||||||
|
admin POST /api/projects/s1/share 200
|
||||||
|
admin GET /api/projects/s1/share 200
|
||||||
|
anon GET /api/share/:token 200
|
||||||
|
anon GET /api/share/doesnotexist 404
|
||||||
|
admin POST /api/projects/s1/outline-share 200
|
||||||
|
anon GET /api/share-outline/:token 200
|
||||||
|
admin GET /api/projects/s1/outline-share 200
|
||||||
|
admin DELETE /api/projects/s1/outline-share 200
|
||||||
|
anon GET /api/share-outline/:token 404
|
||||||
|
admin DELETE /api/projects/s1/share 200
|
||||||
|
admin POST /api/projects/s1/collaborators/invite 200
|
||||||
|
sam POST /api/join/:token 200
|
||||||
|
admin GET /api/projects/s1/collaborators 200
|
||||||
|
sam GET /api/projects/s1/versions 200
|
||||||
|
admin DELETE /api/projects/s1/collaborators/:id 200
|
||||||
|
sam GET /api/admin/users 403
|
||||||
|
admin GET /api/admin/users 200
|
||||||
|
admin GET /api/admin/projects 200
|
||||||
|
admin GET /api/admin/projects/s1 200
|
||||||
|
admin GET /api/admin/signup 200
|
||||||
|
admin PUT /api/admin/signup 400
|
||||||
|
admin PUT /api/admin/signup 200
|
||||||
|
admin POST /api/admin/invites 200
|
||||||
|
admin DELETE /api/admin/invites/:code 200
|
||||||
|
admin GET /api/admin/health 200
|
||||||
|
admin POST /api/admin/backups 200
|
||||||
|
admin GET /api/admin/backups/nope.db 404
|
||||||
|
admin POST /api/admin/backups/projects-2000-01-01-00-00.db/restore 404
|
||||||
|
sam GET /api/auth/me 200
|
||||||
|
admin POST /api/admin/users/:id/reset-password 200
|
||||||
|
sam GET /api/auth/me 401
|
||||||
|
admin DELETE /api/admin/users/:id 200
|
||||||
|
admin DELETE /api/projects/s1 200
|
||||||
|
admin POST /api/auth/logout 200
|
||||||
|
admin GET /api/auth/me 401
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
// Starts the real server (server/index.js) on a spare port with a throwaway data folder and walks the whole API
|
||||||
|
// the way the app does: accounts, studies, synced reader data, sharing, co-teaching, admin. It records the status of
|
||||||
|
// every call, so reorganising the server's files can't quietly change what any route answers.
|
||||||
|
import { afterAll, beforeAll, expect, test } from 'vitest';
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { createServer } from 'net';
|
||||||
|
|
||||||
|
const ADMIN = 'admin@x.test';
|
||||||
|
let proc; let dir; let base;
|
||||||
|
const jars = {}; // who -> cookie
|
||||||
|
const log = [];
|
||||||
|
|
||||||
|
const freePort = () => new Promise((resolve) => { const s = createServer(); s.listen(0, () => { const { port } = s.address(); s.close(() => resolve(port)); }); });
|
||||||
|
|
||||||
|
async function call(who, method, path, body, headers = {}) {
|
||||||
|
const res = await fetch(base + path, {
|
||||||
|
method, headers: { 'Content-Type': 'application/json', ...(jars[who] ? { Cookie: jars[who] } : {}), ...headers },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body), redirect: 'manual',
|
||||||
|
});
|
||||||
|
const set = res.headers.get('set-cookie');
|
||||||
|
if (set) jars[who] = set.split(';')[0];
|
||||||
|
const text = await res.text();
|
||||||
|
let json = null; try { json = JSON.parse(text); } catch { /* not JSON */ }
|
||||||
|
log.push(`${who} ${method} ${path.replace(/[0-9a-f]{20,}/g, ':token').replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g, ':id').replace(/[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}/g, ':code')} ${res.status}`);
|
||||||
|
return { status: res.status, json, text };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'smoke-'));
|
||||||
|
const port = await freePort();
|
||||||
|
base = `http://127.0.0.1:${port}`;
|
||||||
|
proc = spawn('node', ['server/index.js'], {
|
||||||
|
env: { ...process.env, PORT: String(port), DATA_DIR: dir, ADMIN_EMAIL: ADMIN, SESSION_SECRET: 'smoke', BACKUP_DISABLED: '1', NODE_ENV: 'development', IQBIBLE_API_KEY: '' },
|
||||||
|
stdio: 'ignore',
|
||||||
|
});
|
||||||
|
for (let i = 0; i < 100; i += 1) {
|
||||||
|
try { if ((await fetch(`${base}/api/health`)).ok) return; } catch { /* not up yet */ }
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
}
|
||||||
|
throw new Error('server did not start');
|
||||||
|
}, 20000);
|
||||||
|
afterAll(() => { proc?.kill(); rmSync(dir, { recursive: true, force: true }); });
|
||||||
|
|
||||||
|
const study = (id, title = 'Romans') => ({ id, title, translation: 'BSB', lastEdited: 1, chapters: [{ book: 'Romans', bookAbbrev: 'ROM', chapter: '8', verses: [{ number: 1, text: 'x' }], chunks: [] }] });
|
||||||
|
|
||||||
|
test('the whole API answers the way it always has', async () => {
|
||||||
|
// ---- public ----
|
||||||
|
expect((await call('anon', 'GET', '/api/health')).status).toBe(200);
|
||||||
|
expect((await call('anon', 'GET', '/api/auth/signup-mode')).json.mode).toBe('open');
|
||||||
|
expect((await call('anon', 'GET', '/api/auth/me')).status).toBe(401);
|
||||||
|
expect((await call('anon', 'GET', '/api/projects')).status).toBe(401);
|
||||||
|
expect((await call('anon', 'GET', '/api/admin/users')).status).toBe(401);
|
||||||
|
|
||||||
|
// ---- accounts ----
|
||||||
|
expect((await call('admin', 'POST', '/api/auth/register', { email: ADMIN, password: 'correct-horse-1' })).status).toBe(200);
|
||||||
|
expect((await call('sam', 'POST', '/api/auth/register', { email: 'sam@x.test', password: 'correct-horse-2' })).status).toBe(200);
|
||||||
|
expect((await call('anon', 'POST', '/api/auth/register', { email: 'bad', password: 'x' })).status).toBe(400);
|
||||||
|
expect((await call('anon', 'POST', '/api/auth/login', { email: ADMIN, password: 'wrong-password' })).status).toBe(401);
|
||||||
|
expect((await call('admin', 'POST', '/api/auth/login', { email: ADMIN, password: 'correct-horse-1' })).status).toBe(200);
|
||||||
|
const me = await call('admin', 'GET', '/api/auth/me');
|
||||||
|
expect(me.json).toMatchObject({ email: ADMIN, isAdmin: true, totpEnabled: false, passkeyCount: 0 });
|
||||||
|
expect((await call('sam', 'GET', '/api/auth/me')).json.isAdmin).toBe(false);
|
||||||
|
expect((await call('anon', 'POST', '/api/auth/forgot', { email: 'sam@x.test' })).json.ok).toBe(true);
|
||||||
|
expect((await call('anon', 'POST', '/api/auth/reset', { email: 'sam@x.test', code: 'nope', newPassword: 'another-pass-3' })).status).toBe(400);
|
||||||
|
expect((await call('admin', 'PATCH', '/api/auth/profile', { podcastName: 'My Show' })).status).toBe(200);
|
||||||
|
expect((await call('admin', 'PATCH', '/api/auth/reminder-settings', { reminderEnabled: true, ntfyTopic: '', reminderTime: '08:00' })).status).toBe(400);
|
||||||
|
expect((await call('admin', 'POST', '/api/auth/reminder-test', { ntfyTopic: 'bad topic!' })).status).toBe(400);
|
||||||
|
expect((await call('admin', 'POST', '/api/auth/change-password', { currentPassword: 'nope', newPassword: 'brand-new-pass-4' })).status).toBe(401);
|
||||||
|
expect((await call('admin', 'POST', '/api/auth/mfa/setup')).json.secret).toBeTruthy();
|
||||||
|
expect((await call('admin', 'POST', '/api/auth/mfa/enable', { token: '000000' })).status).toBe(401);
|
||||||
|
expect((await call('anon', 'POST', '/api/auth/mfa/verify', { token: '000000' })).status).toBe(400);
|
||||||
|
expect((await call('admin', 'GET', '/api/account/export')).json.app).toBeTruthy();
|
||||||
|
|
||||||
|
// ---- studies ----
|
||||||
|
expect((await call('admin', 'PUT', '/api/projects/s1', study('s1'))).status).toBe(200);
|
||||||
|
expect((await call('admin', 'PUT', '/api/projects/s1', study('other'))).status).toBe(400);
|
||||||
|
expect((await call('admin', 'PUT', '/api/projects/s1', { id: 's1' })).status).toBe(400);
|
||||||
|
expect((await call('admin', 'GET', '/api/projects')).json).toHaveLength(1);
|
||||||
|
expect((await call('admin', 'GET', '/api/projects/s1')).json.title).toBe('Romans');
|
||||||
|
expect((await call('sam', 'GET', '/api/projects/s1')).status).toBe(404);
|
||||||
|
expect((await call('admin', 'PUT', '/api/projects/s1', { ...study('s1', 'Romans 8'), lastEdited: 2 }, { 'X-Base-Edited': '1' })).status).toBe(409); // another device saved since
|
||||||
|
expect((await call('admin', 'GET', '/api/projects/s1/versions')).json.versions).toBeInstanceOf(Array);
|
||||||
|
expect((await call('admin', 'GET', '/api/projects/s1/versions/999')).status).toBe(404);
|
||||||
|
expect((await call('admin', 'POST', '/api/projects/s1/versions/999/restore')).status).toBe(404);
|
||||||
|
expect((await call('sam', 'GET', '/api/projects/s1/versions')).status).toBe(404);
|
||||||
|
|
||||||
|
// ---- synced reader data ----
|
||||||
|
expect((await call('admin', 'PUT', '/api/reader/ink/ROM/8', { strokes: [{ p: 1 }] })).status).toBe(200);
|
||||||
|
expect((await call('admin', 'PUT', '/api/reader/ink/ROM/8', { strokes: 'no' })).status).toBe(400);
|
||||||
|
expect((await call('admin', 'PUT', '/api/reader/ink/ROMANS/8', { strokes: [] })).status).toBe(400);
|
||||||
|
expect(Object.keys((await call('admin', 'GET', '/api/reader/ink')).json)).toHaveLength(1);
|
||||||
|
expect((await call('admin', 'PUT', '/api/prefs', { prefs: { theme: { v: 'dark', at: 5 } } })).json.prefs.theme.v).toBe('dark');
|
||||||
|
expect((await call('admin', 'GET', '/api/prefs')).json.prefs.theme.v).toBe('dark');
|
||||||
|
expect((await call('admin', 'PUT', '/api/reader/marks', { bookmarks: { 'JHN-3-16': '#fef08a' }, highlights: [] })).json.ok).toBe(true);
|
||||||
|
expect((await call('admin', 'GET', '/api/reader/marks')).json.bookmarks['JHN-3-16']).toBe('#fef08a');
|
||||||
|
expect((await call('admin', 'PUT', '/api/reader/marks', 'x')).status).toBe(400);
|
||||||
|
expect((await call('admin', 'PUT', '/api/prayers', { prayers: [{ id: 'p1', text: 'Peace', createdAt: 1, updatedAt: 2 }] })).json.prayers).toHaveLength(1);
|
||||||
|
expect((await call('admin', 'GET', '/api/prayers')).json.prayers).toHaveLength(1);
|
||||||
|
expect((await call('admin', 'PUT', '/api/reader/history', { 'JHN-3': 1 })).status).toBe(200);
|
||||||
|
expect((await call('admin', 'GET', '/api/reader/history')).status).toBe(200);
|
||||||
|
expect((await call('admin', 'PUT', '/api/reading-plans', { progress: [{ id: 'r1' }], customPlans: [], activity: ['2026-10-01'] })).json.ok).toBe(true);
|
||||||
|
expect((await call('admin', 'PUT', '/api/reading-plans', { progress: 'no' })).status).toBe(400);
|
||||||
|
expect((await call('admin', 'GET', '/api/reading-plans')).json.progress).toHaveLength(1);
|
||||||
|
|
||||||
|
// ---- other areas mounted on the app ----
|
||||||
|
expect((await call('admin', 'GET', '/api/iq/status')).json.available).toBe(false);
|
||||||
|
expect((await call('admin', 'GET', '/api/iq/nope')).status).toBe(404);
|
||||||
|
expect((await call('admin', 'GET', '/api/passkeys')).json).toEqual([]);
|
||||||
|
expect((await call('admin', 'GET', '/api/sessions')).json).toHaveLength(1);
|
||||||
|
expect((await call('admin', 'POST', '/api/auth/passkey/options')).json.challenge).toBeTruthy();
|
||||||
|
expect((await call('anon', 'GET', '/api/bible/nope')).status).toBeGreaterThanOrEqual(400);
|
||||||
|
|
||||||
|
// ---- sharing ----
|
||||||
|
const share = await call('admin', 'POST', '/api/projects/s1/share', {});
|
||||||
|
expect(share.status).toBe(200);
|
||||||
|
expect((await call('admin', 'GET', '/api/projects/s1/share')).status).toBe(200);
|
||||||
|
expect((await call('anon', 'GET', `/api/share/${share.json.shareToken}`)).status).toBe(200);
|
||||||
|
expect((await call('anon', 'GET', '/api/share/doesnotexist')).status).toBe(404);
|
||||||
|
const outline = await call('admin', 'POST', '/api/projects/s1/outline-share', {});
|
||||||
|
expect((await call('anon', 'GET', `/api/share-outline/${outline.json.shareToken}`)).status).toBe(200);
|
||||||
|
expect((await call('admin', 'GET', '/api/projects/s1/outline-share')).status).toBe(200);
|
||||||
|
expect((await call('admin', 'DELETE', '/api/projects/s1/outline-share')).status).toBe(200);
|
||||||
|
expect((await call('anon', 'GET', `/api/share-outline/${outline.json.shareToken}`)).status).toBe(404);
|
||||||
|
expect((await call('admin', 'DELETE', '/api/projects/s1/share')).status).toBe(200);
|
||||||
|
const invite = await call('admin', 'POST', '/api/projects/s1/collaborators/invite');
|
||||||
|
expect(invite.status).toBe(200);
|
||||||
|
expect((await call('sam', 'POST', `/api/join/${invite.json.token}`)).status).toBe(200);
|
||||||
|
expect((await call('admin', 'GET', '/api/projects/s1/collaborators')).status).toBe(200);
|
||||||
|
expect((await call('sam', 'GET', '/api/projects/s1/versions')).status).toBe(200);
|
||||||
|
expect((await call('admin', 'DELETE', `/api/projects/s1/collaborators/${me.json.id}`)).status).toBe(200);
|
||||||
|
|
||||||
|
// ---- admin ----
|
||||||
|
expect((await call('sam', 'GET', '/api/admin/users')).status).toBe(403);
|
||||||
|
expect((await call('admin', 'GET', '/api/admin/users')).json).toHaveLength(2);
|
||||||
|
expect((await call('admin', 'GET', '/api/admin/projects')).status).toBe(200);
|
||||||
|
expect((await call('admin', 'GET', '/api/admin/projects/s1')).status).toBe(200);
|
||||||
|
expect((await call('admin', 'GET', '/api/admin/signup')).json.mode).toBe('open');
|
||||||
|
expect((await call('admin', 'PUT', '/api/admin/signup', { mode: 'nonsense' })).status).toBe(400);
|
||||||
|
expect((await call('admin', 'PUT', '/api/admin/signup', { mode: 'invite' })).status).toBe(200);
|
||||||
|
const code = await call('admin', 'POST', '/api/admin/invites', { note: 'x', days: 7 });
|
||||||
|
expect(code.status).toBe(200);
|
||||||
|
expect((await call('admin', 'DELETE', `/api/admin/invites/${encodeURIComponent(code.json.code ?? 'none')}`)).status).toBeGreaterThanOrEqual(200);
|
||||||
|
const health = await call('admin', 'GET', '/api/admin/health');
|
||||||
|
expect(health.json).toMatchObject({ iqConfigured: false });
|
||||||
|
expect(health.json.signinFailures.length).toBeGreaterThan(0); // the wrong password above
|
||||||
|
expect((await call('admin', 'POST', '/api/admin/backups')).status).toBe(200);
|
||||||
|
expect((await call('admin', 'GET', '/api/admin/backups/nope.db')).status).toBe(404);
|
||||||
|
expect((await call('admin', 'POST', '/api/admin/backups/projects-2000-01-01-00-00.db/restore', { confirm: 'x' })).status).toBe(404);
|
||||||
|
const samId = (await call('sam', 'GET', '/api/auth/me')).json.id;
|
||||||
|
expect((await call('admin', 'POST', `/api/admin/users/${samId}/reset-password`)).json.temporaryPassword).toBeTruthy();
|
||||||
|
expect((await call('sam', 'GET', '/api/auth/me')).status).toBe(401); // signed out everywhere
|
||||||
|
expect((await call('admin', 'DELETE', `/api/admin/users/${samId}`)).status).toBe(200);
|
||||||
|
|
||||||
|
// ---- finishing ----
|
||||||
|
expect((await call('admin', 'DELETE', '/api/projects/s1')).status).toBe(200);
|
||||||
|
expect((await call('admin', 'POST', '/api/auth/logout')).status).toBe(200);
|
||||||
|
expect((await call('admin', 'GET', '/api/auth/me')).status).toBe(401);
|
||||||
|
|
||||||
|
// Every call and its status, in order. Run with UPDATE_SMOKE=1 to rewrite it after a deliberate API change.
|
||||||
|
const file = new URL('./routes.smoke.expected.txt', import.meta.url);
|
||||||
|
if (process.env.UPDATE_SMOKE === '1') writeFileSync(file, `${log.join('\n')}\n`);
|
||||||
|
expect(`${log.join('\n')}\n`).toBe(readFileSync(file, 'utf8'));
|
||||||
|
}, 60000);
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
// Account settings: profile, reminder settings, test notification, and the full data export.
|
||||||
|
import { getAllProjects, getAllReaderInk, getPrayers, getProject, getReaderHistory, getReaderMarks, getReadingProgress, getUserById, getUserPrefs, setPodcastName, setPrayerReminder, setReminderSettings, setVotdSettings } from '../db.js';
|
||||||
|
import { requireAuth } from '../auth.js';
|
||||||
|
import { votdAvailable } from '../votd.js';
|
||||||
|
import { assertPublicUrl, isValidTimeZone, sendTestReminder } from '../reminders.js';
|
||||||
|
import { accountExportName, buildAccountExport } from '../accountExport.js';
|
||||||
|
|
||||||
|
export function registerAccountRoutes(app) {
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PATCH /api/auth/profile — update account-level settings (currently just podcastName)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
app.patch('/api/auth/profile', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const podcastName = String(req.body?.podcastName ?? '').trim().slice(0, 200);
|
||||||
|
setPodcastName(req.session.userId, podcastName || null);
|
||||||
|
res.json({ podcastName: podcastName || null });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('PATCH /api/auth/profile error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to save profile.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PATCH /api/auth/reminder-settings — daily reading reminder via ntfy.sh
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
app.patch('/api/auth/reminder-settings', requireAuth, async (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 (ntfyUrl) {
|
||||||
|
try { await assertPublicUrl(ntfyUrl); } catch (err) { return res.status(400).json({ error: err.message }); }
|
||||||
|
}
|
||||||
|
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.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the verse of the day is optional, and only offered when the server can look it up
|
||||||
|
const votdEnabled = !!req.body?.votdEnabled && votdAvailable();
|
||||||
|
const votdTime = String(req.body?.votdTime ?? '').trim();
|
||||||
|
if (votdEnabled) {
|
||||||
|
if (!ntfyTopic) return res.status(400).json({ error: 'A topic is required to send the verse of the day.' });
|
||||||
|
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(votdTime)) return res.status(400).json({ error: 'The verse of the day time must be in HH:MM format.' });
|
||||||
|
}
|
||||||
|
setVotdSettings(req.session.userId, { votdEnabled, votdTime: votdTime || null });
|
||||||
|
const prayerEnabled = !!req.body?.prayerEnabled;
|
||||||
|
const prayerTime = String(req.body?.prayerTime ?? '').trim();
|
||||||
|
if (prayerEnabled) {
|
||||||
|
if (!ntfyTopic) return res.status(400).json({ error: 'A topic is required to send the prayer reminder.' });
|
||||||
|
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(prayerTime)) return res.status(400).json({ error: 'The prayer reminder time must be in HH:MM format.' });
|
||||||
|
}
|
||||||
|
setPrayerReminder(req.session.userId, { prayerEnabled, prayerTime: prayerTime || null });
|
||||||
|
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,
|
||||||
|
votdEnabled, votdTime: votdTime || null, prayerEnabled, prayerTime: prayerTime || null,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('PATCH /api/auth/reminder-settings error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to save reminder settings.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/account/export — everything this account holds, as one restorable file
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
app.get('/api/account/export', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.session.userId;
|
||||||
|
const user = getUserById(userId);
|
||||||
|
const backup = buildAccountExport(user, {
|
||||||
|
projects: () => getAllProjects(userId).map((p) => getProject(p.id, userId)).filter(Boolean),
|
||||||
|
prefs: () => getUserPrefs(userId),
|
||||||
|
marks: () => getReaderMarks(userId),
|
||||||
|
ink: () => getAllReaderInk(userId),
|
||||||
|
plans: () => getReadingProgress(userId),
|
||||||
|
history: () => getReaderHistory(userId),
|
||||||
|
prayers: () => getPrayers(userId).filter((p) => !p.deleted),
|
||||||
|
});
|
||||||
|
res.set('Content-Disposition', `attachment; filename="${accountExportName()}"`);
|
||||||
|
res.json(backup);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('GET /api/account/export error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to export your data.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
// Admin only: users, studies, sign-up rules and invites, server health and snapshots.
|
||||||
|
import { adminDeleteProject, adminDeleteUser, adminGetAllProjects, adminGetAllUsers, adminGetProject, createInvite, dataDir, deleteInvite, destroyAllSessionsForUser, getSetting, getUserById, listInvites, setSetting, setUserPassword } from '../db.js';
|
||||||
|
import { generateTemporaryPassword, hashPassword, requireAdmin, requireAuth } from '../auth.js';
|
||||||
|
import { SIGNUP_MODES, generateInviteCode, isSignupMode, normalizeInviteCode } from '../signup.js';
|
||||||
|
import { restoreSnapshot, runBackup, snapshotPath } from '../backups.js';
|
||||||
|
import { backupStatus, diskUse, logEvent, recentEvents, uptimeSeconds } from '../health.js';
|
||||||
|
|
||||||
|
export function registerAdminRoutes(app) {
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Admin — restricted to the single designated admin account (see ADMIN_EMAIL
|
||||||
|
// in server/auth.js). Full visibility/control over every user and project.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
app.get('/api/admin/users', requireAuth, requireAdmin, (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(adminGetAllUsers());
|
||||||
|
} catch (err) {
|
||||||
|
console.error('GET /api/admin/users error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to list users.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/admin/users/:id', requireAuth, requireAdmin, (req, res) => {
|
||||||
|
try {
|
||||||
|
if (req.params.id === req.session.userId) {
|
||||||
|
return res.status(400).json({ error: "Can't delete your own admin account." });
|
||||||
|
}
|
||||||
|
adminDeleteUser(req.params.id);
|
||||||
|
destroyAllSessionsForUser(req.params.id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('DELETE /api/admin/users/:id error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to delete user.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/admin/users/:id/reset-password — sets a random temporary password and
|
||||||
|
// signs the user out everywhere, since there's no self-service "forgot password" flow yet.
|
||||||
|
app.post('/api/admin/users/:id/reset-password', requireAuth, requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const temporaryPassword = generateTemporaryPassword();
|
||||||
|
const passwordHash = await hashPassword(temporaryPassword);
|
||||||
|
setUserPassword(req.params.id, passwordHash);
|
||||||
|
destroyAllSessionsForUser(req.params.id);
|
||||||
|
res.json({ temporaryPassword });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('POST /api/admin/users/:id/reset-password error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to reset password.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Who may sign up, and the invite codes for it
|
||||||
|
// Server health: disk use, snapshots, and a short log of failures (in memory; resets on restart).
|
||||||
|
app.get('/api/admin/health', requireAuth, requireAdmin, (_req, res) => {
|
||||||
|
try {
|
||||||
|
const emailOf = (id) => (id ? getUserById(id)?.email ?? null : null);
|
||||||
|
const withUser = (e) => ({ ...e, email: emailOf(e.userId) });
|
||||||
|
res.json({
|
||||||
|
uptimeSeconds: uptimeSeconds(), node: process.version, dataDir: dataDir(),
|
||||||
|
disk: diskUse(), backups: backupStatus(),
|
||||||
|
iqConfigured: !!process.env.IQBIBLE_API_KEY,
|
||||||
|
signinFailures: recentEvents('signin', 30).map(withUser),
|
||||||
|
notifyFailures: recentEvents('notify', 30).map(withUser),
|
||||||
|
backupFailures: recentEvents('backup', 10),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('GET /api/admin/health error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to read server health.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/admin/backups', requireAuth, requireAdmin, async (_req, res) => {
|
||||||
|
try {
|
||||||
|
await runBackup();
|
||||||
|
res.json(backupStatus());
|
||||||
|
} catch (err) {
|
||||||
|
console.error('POST /api/admin/backups error:', err);
|
||||||
|
logEvent('backup', err.message);
|
||||||
|
res.status(500).json({ error: 'The snapshot could not be made.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/admin/backups/:name', requireAuth, requireAdmin, (req, res) => {
|
||||||
|
const file = snapshotPath(req.params.name);
|
||||||
|
if (!file) return res.status(404).json({ error: 'No such snapshot.' });
|
||||||
|
res.download(file, req.params.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restoring replaces every account's data, so the snapshot's name has to be typed back as confirmation.
|
||||||
|
app.post('/api/admin/backups/:name/restore', requireAuth, requireAdmin, async (req, res) => {
|
||||||
|
if (!snapshotPath(req.params.name)) return res.status(404).json({ error: 'No such snapshot.' });
|
||||||
|
if (req.body?.confirm !== req.params.name) return res.status(400).json({ error: 'Type the snapshot’s name to confirm.' });
|
||||||
|
try {
|
||||||
|
const result = await restoreSnapshot(req.params.name);
|
||||||
|
res.json({ restored: result.restored, safetyCopy: result.safetyCopy.split('/').pop() });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('POST /api/admin/backups/:name/restore error:', err);
|
||||||
|
res.status(500).json({ error: 'The snapshot could not be restored.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/admin/signup', requireAuth, requireAdmin, (_req, res) => {
|
||||||
|
res.json({ mode: getSetting('signup', 'open'), invites: listInvites() });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/admin/signup', requireAuth, requireAdmin, (req, res) => {
|
||||||
|
if (!isSignupMode(req.body?.mode)) return res.status(400).json({ error: `Mode must be one of: ${SIGNUP_MODES.join(', ')}.` });
|
||||||
|
setSetting('signup', req.body.mode);
|
||||||
|
res.json({ mode: req.body.mode });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/admin/invites', requireAuth, requireAdmin, (req, res) => {
|
||||||
|
const days = Math.min(90, Math.max(1, Number(req.body?.days) || 7));
|
||||||
|
const code = generateInviteCode();
|
||||||
|
createInvite(code, String(req.body?.note ?? '').trim().slice(0, 80), Date.now() + days * 86_400_000);
|
||||||
|
res.json({ code, expiresInDays: days });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/admin/invites/:code', requireAuth, requireAdmin, (req, res) => {
|
||||||
|
deleteInvite(normalizeInviteCode(req.params.code));
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/admin/projects', requireAuth, requireAdmin, (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(adminGetAllProjects());
|
||||||
|
} catch (err) {
|
||||||
|
console.error('GET /api/admin/projects error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to list projects.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/admin/projects/:id', requireAuth, requireAdmin, (req, res) => {
|
||||||
|
try {
|
||||||
|
const project = adminGetProject(req.params.id);
|
||||||
|
if (!project) return res.status(404).json({ error: 'Project not found.' });
|
||||||
|
res.json(project);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('GET /api/admin/projects/:id error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to load project.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/admin/projects/:id', requireAuth, requireAdmin, (req, res) => {
|
||||||
|
try {
|
||||||
|
adminDeleteProject(req.params.id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('DELETE /api/admin/projects/:id error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to delete project.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
// Accounts: sign up, sign in (password + two-factor), password reset, change password, two-factor setup.
|
||||||
|
import QRCode from 'qrcode';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { adminDeleteUser, bumpPasswordResetAttempts, claimOrphanProjects, clearPasswordReset, countUsers, createUser, destroyAllSessionsForUser, disableTotp, enableTotp, findOpenInvite, getPasswordReset, getSetting, getUserByEmail, getUserById, setBackupCodeHashes, setPasswordReset, setUserPassword, useInvite } from '../db.js';
|
||||||
|
import { consumeBackupCode, generateBackupCodes, generateResetCode, generateTotpSecret, hashBackupCodes, hashPassword, isAdminEmail, isValidEmail, isValidPassword, normalizeResetCode, requireAuth, totpKeyUri, verifyPassword, verifyTotpToken } from '../auth.js';
|
||||||
|
import { sendNtfy } from '../reminders.js';
|
||||||
|
import { noteSignIn } from '../devices.js';
|
||||||
|
import { checkSignup, normalizeInviteCode } from '../signup.js';
|
||||||
|
import { logEvent } from '../health.js';
|
||||||
|
import { userPayload } from '../userPayload.js';
|
||||||
|
import { TOO_MANY, clearFailures, isLimited, recordFailure } from '../limiter.js';
|
||||||
|
|
||||||
|
export function registerAuthRoutes(app) {
|
||||||
|
// Compared against when an email isn't registered, so a login takes the same time either way.
|
||||||
|
const DUMMY_HASH = '$2b$10$UbGSJvxosy5oInS6zfvqM.gWOUv9ypVg81ou.jWOswHyYgUjpkhJK';
|
||||||
|
|
||||||
|
// TOTP codes already used (userId -> code), so a captured code can't be replayed inside its time window.
|
||||||
|
const _usedTotp = new Map();
|
||||||
|
function totpAlreadyUsed(userId, token) { return _usedTotp.get(userId) === token; }
|
||||||
|
function markTotpUsed(userId, token) {
|
||||||
|
_usedTotp.set(userId, token);
|
||||||
|
setTimeout(() => { if (_usedTotp.get(userId) === token) _usedTotp.delete(userId); }, 90 * 1000).unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Auth
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
app.post('/api/auth/register', async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (isLimited(`ip:${req.ip}`)) return res.status(429).json(TOO_MANY);
|
||||||
|
recordFailure(`ip:${req.ip}`); // every registration attempt counts
|
||||||
|
const email = String(req.body?.email ?? '').trim().toLowerCase();
|
||||||
|
const password = String(req.body?.password ?? '');
|
||||||
|
|
||||||
|
const inviteCode = normalizeInviteCode(req.body?.invite);
|
||||||
|
const allowed = checkSignup({ mode: getSetting('signup', 'open'), userCount: countUsers(), inviteOk: !!findOpenInvite(inviteCode) });
|
||||||
|
if (!allowed.ok) return res.status(allowed.status).json({ error: allowed.error });
|
||||||
|
if (!isValidEmail(email)) {
|
||||||
|
return res.status(400).json({ error: 'Enter a valid email address.' });
|
||||||
|
}
|
||||||
|
if (!isValidPassword(password)) {
|
||||||
|
return res.status(400).json({ error: 'Password must be at least 8 characters.' });
|
||||||
|
}
|
||||||
|
if (getUserByEmail(email)) {
|
||||||
|
return res.status(409).json({ error: 'An account with that email already exists.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await hashPassword(password);
|
||||||
|
const user = createUser({ id: randomUUID(), email, passwordHash });
|
||||||
|
if (getSetting('signup', 'open') === 'invite' && countUsers() > 1 && !useInvite(inviteCode, user.id)) {
|
||||||
|
adminDeleteUser(user.id); // someone used the code between the check and now
|
||||||
|
return res.status(403).json({ error: 'That invite code was just used. Ask for a new one.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// The very first account inherits any projects created before multi-user support existed.
|
||||||
|
if (countUsers() === 1) {
|
||||||
|
claimOrphanProjects(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
req.session.regenerate((err) => {
|
||||||
|
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
||||||
|
req.session.userId = user.id;
|
||||||
|
noteSignIn(req, user, 'register');
|
||||||
|
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) {
|
||||||
|
console.error('POST /api/auth/register error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to register.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/auth/login', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const email = String(req.body?.email ?? '').trim().toLowerCase();
|
||||||
|
const password = String(req.body?.password ?? '');
|
||||||
|
const keys = [`ip:${req.ip}`, `acct:${email}`];
|
||||||
|
if (isLimited(...keys)) return res.status(429).json(TOO_MANY);
|
||||||
|
|
||||||
|
const user = getUserByEmail(email);
|
||||||
|
const valid = await verifyPassword(password, user?.passwordHash ?? DUMMY_HASH) && !!user;
|
||||||
|
if (!valid) {
|
||||||
|
recordFailure(...keys);
|
||||||
|
logEvent('signin', `Wrong password for ${email.slice(0, 80) || '(blank)'} from ${req.ip}`);
|
||||||
|
return res.status(401).json({ error: 'Incorrect email or password.' });
|
||||||
|
}
|
||||||
|
clearFailures(`acct:${email}`);
|
||||||
|
|
||||||
|
req.session.regenerate((err) => {
|
||||||
|
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
||||||
|
if (user.totpEnabled) {
|
||||||
|
// Password is correct, but the session stays unauthenticated (no userId)
|
||||||
|
// until a valid TOTP/backup code lands on /api/auth/mfa/verify.
|
||||||
|
req.session.pendingUserId = user.id;
|
||||||
|
return res.json({ mfaRequired: true });
|
||||||
|
}
|
||||||
|
req.session.userId = user.id;
|
||||||
|
noteSignIn(req, user, 'password');
|
||||||
|
res.json(userPayload(user));
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('POST /api/auth/login error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to log in.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/auth/mfa/verify', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const pendingUserId = req.session?.pendingUserId;
|
||||||
|
if (!pendingUserId) {
|
||||||
|
return res.status(400).json({ error: 'No sign-in in progress.' });
|
||||||
|
}
|
||||||
|
const keys = [`ip:${req.ip}`, `mfa:${pendingUserId}`];
|
||||||
|
if (isLimited(...keys)) return res.status(429).json(TOO_MANY);
|
||||||
|
const user = getUserById(pendingUserId);
|
||||||
|
if (!user || !user.totpEnabled) {
|
||||||
|
return res.status(400).json({ error: 'No sign-in in progress.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = req.body?.token;
|
||||||
|
const backupCode = req.body?.backupCode;
|
||||||
|
let ok = token ? verifyTotpToken(String(token), user.totpSecret) && !totpAlreadyUsed(user.id, String(token)) : false;
|
||||||
|
if (ok) markTotpUsed(user.id, String(token));
|
||||||
|
|
||||||
|
if (!ok && backupCode) {
|
||||||
|
const remaining = await consumeBackupCode(String(backupCode), user.backupCodeHashes);
|
||||||
|
if (remaining) {
|
||||||
|
setBackupCodeHashes(user.id, remaining);
|
||||||
|
ok = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ok) {
|
||||||
|
recordFailure(...keys);
|
||||||
|
logEvent('signin', `Wrong two-factor code for ${user.email} from ${req.ip}`, { userId: user.id });
|
||||||
|
return res.status(401).json({ error: 'Invalid code.' });
|
||||||
|
}
|
||||||
|
clearFailures(`mfa:${pendingUserId}`);
|
||||||
|
|
||||||
|
req.session.regenerate((err) => {
|
||||||
|
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
||||||
|
req.session.userId = user.id;
|
||||||
|
noteSignIn(req, user, 'mfa');
|
||||||
|
res.json(userPayload(user));
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('POST /api/auth/mfa/verify error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to verify code.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Forgot password. There is no email here, so the one-time code goes to the account's ntfy topic (the same
|
||||||
|
// one used for reading reminders). Accounts without a topic still go through the admin. The answer is the
|
||||||
|
// same whether or not the account exists, so this can't be used to find out who has one.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const RESET_MINUTES = 15;
|
||||||
|
const RESET_REPLY = { ok: true, message: 'If that account has a notification topic set up, a reset code was just sent to it. Codes last 15 minutes. No topic? Ask the administrator for a temporary password.' };
|
||||||
|
|
||||||
|
app.post('/api/auth/forgot', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const email = String(req.body?.email ?? '').trim().toLowerCase();
|
||||||
|
const keys = [`ip:${req.ip}`, `forgot:${email}`];
|
||||||
|
if (isLimited(...keys)) return res.status(429).json(TOO_MANY);
|
||||||
|
recordFailure(...keys); // every request counts
|
||||||
|
const user = isValidEmail(email) ? getUserByEmail(email) : null;
|
||||||
|
if (user?.ntfyTopic) {
|
||||||
|
const code = generateResetCode();
|
||||||
|
setPasswordReset(user.id, await hashPassword(code), Date.now() + RESET_MINUTES * 60 * 1000);
|
||||||
|
sendNtfy(user, { title: 'Password reset code', body: `Your Bible Study reset code is ${code}. It works for ${RESET_MINUTES} minutes. If you didn't ask for it, ignore this.`, tags: 'key' })
|
||||||
|
.catch((err) => console.error('Password reset notification failed:', err.message));
|
||||||
|
}
|
||||||
|
res.json(RESET_REPLY);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('POST /api/auth/forgot error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to start the reset.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/auth/reset', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const email = String(req.body?.email ?? '').trim().toLowerCase();
|
||||||
|
const keys = [`ip:${req.ip}`, `reset:${email}`];
|
||||||
|
if (isLimited(...keys)) return res.status(429).json(TOO_MANY);
|
||||||
|
const fail = (error) => { recordFailure(...keys); return res.status(400).json({ error }); };
|
||||||
|
const newPassword = String(req.body?.newPassword ?? '');
|
||||||
|
if (!isValidPassword(newPassword)) return res.status(400).json({ error: 'New password must be at least 8 characters.' });
|
||||||
|
|
||||||
|
const user = getUserByEmail(email);
|
||||||
|
const reset = user ? getPasswordReset(user.id) : null;
|
||||||
|
const code = normalizeResetCode(req.body?.code);
|
||||||
|
// Always spend one bcrypt compare, so timing doesn't show whether a code was issued
|
||||||
|
const codeOk = await verifyPassword(code, reset?.codeHash ?? DUMMY_HASH) && !!reset && reset.attempts < 5;
|
||||||
|
if (reset && !codeOk) bumpPasswordResetAttempts(user.id);
|
||||||
|
if (!codeOk) return fail('That code is wrong or has expired. Ask for a new one.');
|
||||||
|
if (user.totpEnabled) {
|
||||||
|
const token = String(req.body?.token ?? '');
|
||||||
|
if (!verifyTotpToken(token, user.totpSecret) || totpAlreadyUsed(user.id, token)) return fail('Enter the current 6-digit code from your authenticator app too.');
|
||||||
|
markTotpUsed(user.id, token);
|
||||||
|
}
|
||||||
|
setUserPassword(user.id, await hashPassword(newPassword));
|
||||||
|
clearPasswordReset(user.id);
|
||||||
|
destroyAllSessionsForUser(user.id);
|
||||||
|
clearFailures(...keys);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('POST /api/auth/reset error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to reset the password.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// What the sign-in page needs to know: whether to offer "create an account", and whether it needs an invite code.
|
||||||
|
app.get('/api/auth/signup-mode', (_req, res) => {
|
||||||
|
res.json({ mode: countUsers() === 0 ? 'open' : getSetting('signup', 'open') });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/auth/logout', (req, res) => {
|
||||||
|
req.session.destroy(() => {
|
||||||
|
res.clearCookie('connect.sid');
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/auth/me', (req, res) => {
|
||||||
|
const user = req.session?.userId ? getUserById(req.session.userId) : null;
|
||||||
|
if (!user) return res.status(401).json({ error: 'Not signed in.' });
|
||||||
|
res.json(userPayload(user));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /api/auth/change-password — self-service password change (Account Settings)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
app.post('/api/auth/change-password', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = getUserById(req.session.userId);
|
||||||
|
const currentPassword = String(req.body?.currentPassword ?? '');
|
||||||
|
const newPassword = String(req.body?.newPassword ?? '');
|
||||||
|
|
||||||
|
if (isLimited(`pw:${user.id}`)) return res.status(429).json(TOO_MANY);
|
||||||
|
const valid = await verifyPassword(currentPassword, user.passwordHash);
|
||||||
|
if (!valid) {
|
||||||
|
recordFailure(`pw:${user.id}`);
|
||||||
|
return res.status(401).json({ error: 'Current password is incorrect.' });
|
||||||
|
}
|
||||||
|
if (!isValidPassword(newPassword)) {
|
||||||
|
return res.status(400).json({ error: 'New password must be at least 8 characters.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
clearFailures(`pw:${user.id}`);
|
||||||
|
const passwordHash = await hashPassword(newPassword);
|
||||||
|
setUserPassword(user.id, passwordHash);
|
||||||
|
// Sign out every other session (e.g. a stolen one) but keep this one logged in.
|
||||||
|
destroyAllSessionsForUser(user.id, req.sessionID);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('POST /api/auth/change-password error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to change password.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Two-factor auth setup (requires an already-authenticated session)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
app.post('/api/auth/mfa/setup', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = getUserById(req.session.userId);
|
||||||
|
const secret = generateTotpSecret();
|
||||||
|
// Held only in the session until confirmed with a real code — never written
|
||||||
|
// to the DB (and 2FA never turned on) unless /mfa/enable succeeds below.
|
||||||
|
req.session.pendingTotpSecret = secret;
|
||||||
|
QRCode.toDataURL(totpKeyUri(user.email, secret), (err, qrCodeDataUrl) => {
|
||||||
|
if (err) return res.status(500).json({ error: 'Failed to generate QR code.' });
|
||||||
|
res.json({ secret, qrCodeDataUrl });
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('POST /api/auth/mfa/setup error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to start 2FA setup.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/auth/mfa/enable', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const secret = req.session.pendingTotpSecret;
|
||||||
|
if (!secret) {
|
||||||
|
return res.status(400).json({ error: 'Start 2FA setup first.' });
|
||||||
|
}
|
||||||
|
if (isLimited(`mfaset:${req.session.userId}`)) return res.status(429).json(TOO_MANY);
|
||||||
|
if (!verifyTotpToken(String(req.body?.token ?? ''), secret)) {
|
||||||
|
recordFailure(`mfaset:${req.session.userId}`);
|
||||||
|
return res.status(401).json({ error: 'That code didn\'t match. Check your authenticator app and try again.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const backupCodes = generateBackupCodes();
|
||||||
|
const backupCodeHashes = await hashBackupCodes(backupCodes);
|
||||||
|
enableTotp(req.session.userId, secret, backupCodeHashes);
|
||||||
|
delete req.session.pendingTotpSecret;
|
||||||
|
res.json({ backupCodes });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('POST /api/auth/mfa/enable error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to enable 2FA.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/auth/mfa/disable', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = getUserById(req.session.userId);
|
||||||
|
if (isLimited(`pw:${user.id}`)) return res.status(429).json(TOO_MANY);
|
||||||
|
const valid = await verifyPassword(String(req.body?.password ?? ''), user.passwordHash);
|
||||||
|
if (!valid) {
|
||||||
|
recordFailure(`pw:${user.id}`);
|
||||||
|
return res.status(401).json({ error: 'Incorrect password.' });
|
||||||
|
}
|
||||||
|
disableTotp(user.id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('POST /api/auth/mfa/disable error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to disable 2FA.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// Studies: list, load, save (with conflict checks), earlier versions, delete.
|
||||||
|
import { canEditProject, deleteProject, getAllProjects, getProject, getProjectEdited, getVersion, listVersions, restoreVersion, upsertProject } from '../db.js';
|
||||||
|
import { requireAuth } from '../auth.js';
|
||||||
|
import { isSaveConflict, parseBase } from '../projectSync.js';
|
||||||
|
|
||||||
|
export function registerProjectRoutes(app) {
|
||||||
|
// GET /api/projects — list all project summaries owned by the current user
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
app.get('/api/projects', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const projects = getAllProjects(req.session.userId);
|
||||||
|
res.json(projects);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('GET /api/projects error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to list projects.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/projects/:id — fetch a single full project owned by the current user
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
app.get('/api/projects/:id', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const project = getProject(req.params.id, req.session.userId);
|
||||||
|
if (!project) return res.status(404).json({ error: 'Project not found.' });
|
||||||
|
res.json(project);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('GET /api/projects/:id error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to load project.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PUT /api/projects/:id — create or update a project owned by the current user
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
app.put('/api/projects/:id', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const body = req.body;
|
||||||
|
if (!body || typeof body !== 'object') {
|
||||||
|
return res.status(400).json({ error: 'Invalid JSON body.' });
|
||||||
|
}
|
||||||
|
if (!body.id || !body.title || !Array.isArray(body.chapters)) {
|
||||||
|
return res.status(400).json({ error: 'Missing required fields: id, title, chapters.' });
|
||||||
|
}
|
||||||
|
if (body.id !== req.params.id) {
|
||||||
|
return res.status(400).json({ error: 'URL id does not match body 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.' });
|
||||||
|
// 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);
|
||||||
|
if (!saved) {
|
||||||
|
return res.status(403).json({ error: 'That project belongs to a different account.' });
|
||||||
|
}
|
||||||
|
res.json(saved);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('PUT /api/projects/:id error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to save project.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Earlier versions of a study: list, look at one, and put one back (the copy it replaces is kept too)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
app.get('/api/projects/:id/versions', requireAuth, (req, res) => {
|
||||||
|
if (!canEditProject(req.params.id, req.session.userId)) return res.status(404).json({ error: 'Project not found.' });
|
||||||
|
res.json({ versions: listVersions(req.params.id) });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/projects/:id/versions/:versionId', requireAuth, (req, res) => {
|
||||||
|
if (!canEditProject(req.params.id, req.session.userId)) return res.status(404).json({ error: 'Project not found.' });
|
||||||
|
const version = getVersion(req.params.id, Number(req.params.versionId));
|
||||||
|
if (!version) return res.status(404).json({ error: 'That version is gone.' });
|
||||||
|
res.json(version);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/projects/:id/versions/:versionId/restore', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const restored = restoreVersion(req.params.id, Number(req.params.versionId), req.session.userId);
|
||||||
|
if (!restored) return res.status(404).json({ error: 'That version is gone.' });
|
||||||
|
res.json(restored);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('POST restore error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to restore that version.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DELETE /api/projects/:id — remove a project owned by the current user
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
app.delete('/api/projects/:id', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
deleteProject(req.params.id, req.session.userId);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('DELETE /api/projects/:id error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to delete project.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// Reading data synced between devices: ink, preferences, bookmarks and highlights, prayers, reading history, plan progress.
|
||||||
|
import { getAllReaderInk, getPrayers, getReaderHistory, getReaderMarks, getReadingProgress, getUserPrefs, setPrayers, setReaderHistory, setReaderInkPage, setReaderMarks, setReadingProgress, setUserPrefs } from '../db.js';
|
||||||
|
import { requireAuth } from '../auth.js';
|
||||||
|
import { mergePrayers } from '../prayers.js';
|
||||||
|
import { mergeHistory } from '../readerHistory.js';
|
||||||
|
import { cleanMarks } from '../readerMarks.js';
|
||||||
|
import { cleanPrefsPatch, mergePrefs } from '../prefs.js';
|
||||||
|
|
||||||
|
export function registerReaderRoutes(app) {
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Reader ink — cross-device draw annotations on Bible chapters
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// GET /api/reader/ink — all saved ink pages for the current user
|
||||||
|
app.get('/api/reader/ink', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(getAllReaderInk(req.session.userId));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('GET /api/reader/ink error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to load reader ink.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/reader/ink/:book/:chapter — save (upsert) one page's ink
|
||||||
|
app.put('/api/reader/ink/:book/:chapter', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const { book, chapter } = req.params;
|
||||||
|
const { strokes } = req.body ?? {};
|
||||||
|
if (!/^[1-3A-Za-z]{3}$/.test(book) || !/^\d{1,3}$/.test(chapter)) return res.status(400).json({ error: 'Invalid book or chapter.' });
|
||||||
|
if (!Array.isArray(strokes)) return res.status(400).json({ error: 'strokes must be an array.' });
|
||||||
|
if (strokes.length > 20000) return res.status(413).json({ error: 'Too much ink on one page.' });
|
||||||
|
setReaderInkPage(req.session.userId, book, chapter, strokes);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('PUT /api/reader/ink error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to save reader ink.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/prayers — the prayer journal; PUT merges this device's copy in (newer prayer wins) and returns the whole
|
||||||
|
app.get('/api/prayers', requireAuth, (req, res) => {
|
||||||
|
try { res.json({ prayers: mergePrayers(getPrayers(req.session.userId), []) }); } catch (err) {
|
||||||
|
console.error('GET /api/prayers error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to load your prayers.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.put('/api/prayers', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const merged = mergePrayers(getPrayers(req.session.userId), req.body?.prayers);
|
||||||
|
setPrayers(req.session.userId, merged);
|
||||||
|
res.json({ prayers: merged });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('PUT /api/prayers error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to save your prayers.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/reader/history — what has been read, and PUT merges this device's copy in and returns the whole
|
||||||
|
app.get('/api/reader/history', requireAuth, (req, res) => {
|
||||||
|
try { res.json(mergeHistory(getReaderHistory(req.session.userId), {})); } catch (err) {
|
||||||
|
console.error('GET /api/reader/history error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to load your reading history.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.put('/api/reader/history', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const merged = mergeHistory(getReaderHistory(req.session.userId), req.body);
|
||||||
|
setReaderHistory(req.session.userId, merged);
|
||||||
|
res.json(merged);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('PUT /api/reader/history error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to save your reading history.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
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.' });
|
||||||
|
}
|
||||||
|
const { progress, customPlans, activity } = req.body;
|
||||||
|
if ([progress, customPlans, activity].some((v) => v !== undefined && !Array.isArray(v))
|
||||||
|
|| (progress?.length ?? 0) > 500 || (customPlans?.length ?? 0) > 200 || (activity?.length ?? 0) > 20000) {
|
||||||
|
return res.status(400).json({ error: 'progress, customPlans and activity must be reasonably sized lists.' });
|
||||||
|
}
|
||||||
|
setReadingProgress(req.session.userId, { progress: progress ?? [], customPlans: customPlans ?? [], activity: activity ?? [] });
|
||||||
|
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.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
// Sharing: read-only study and outline links, co-teacher invites, and the public pages that open them.
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { acceptEditInvite, clearOutlineShareToken, clearShareToken, countOpenEditInvites, createEditInvite, getSharePassHash, getShareSettings, getSharedByToken, isProjectOwner, listCollaborators, removeCollaborator, setOutlineShareToken, setShareSettings, setShareToken } from '../db.js';
|
||||||
|
import { requireAuth } from '../auth.js';
|
||||||
|
import { checkShareAccess, expiryFromDays, hashPasscode } from '../shareLinks.js';
|
||||||
|
import { buildOutlinePayload } from '../outlineShare.js';
|
||||||
|
import { TOO_MANY, isLimited, recordFailure } from '../limiter.js';
|
||||||
|
|
||||||
|
export function registerSharingRoutes(app) {
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Read-only share links
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// GET /api/projects/:id/share — current share status for the project owner
|
||||||
|
app.get('/api/projects/:id/share', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const settings = getShareSettings(req.params.id, req.session.userId);
|
||||||
|
if (!settings) return res.status(404).json({ error: 'Project not found.' });
|
||||||
|
res.json({ shareToken: settings.token, expiresAt: settings.expiresAt, hasPasscode: settings.hasPasscode });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('GET /api/projects/:id/share error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to load share status.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sets up (or changes) a read-only link: body { expiresInDays?: number | null, passcode?: string | null }.
|
||||||
|
// Leaving a field out keeps what it was; null/"" clears it. Returns the (new or existing) token.
|
||||||
|
async function saveLink(req, res, outline) {
|
||||||
|
try {
|
||||||
|
const userId = req.session.userId;
|
||||||
|
const settings = getShareSettings(req.params.id, userId, outline);
|
||||||
|
if (!settings) return res.status(404).json({ error: 'Project not found.' });
|
||||||
|
const token = settings.token || randomUUID().replace(/-/g, '');
|
||||||
|
if (!settings.token && !(outline ? setOutlineShareToken : setShareToken)(req.params.id, userId, token)) return res.status(404).json({ error: 'Project not found.' });
|
||||||
|
const body = req.body ?? {};
|
||||||
|
const expiresAt = 'expiresInDays' in body ? expiryFromDays(body.expiresInDays) : settings.expiresAt;
|
||||||
|
// a blank passcode field means "keep it"; null clears; a string sets
|
||||||
|
const current = getSharePassHash(req.params.id, userId, outline);
|
||||||
|
const passHash = 'passcode' in body ? (body.passcode === null ? null : await hashPasscode(body.passcode) ?? current) : current;
|
||||||
|
setShareSettings(req.params.id, userId, { expiresAt, passHash }, outline);
|
||||||
|
res.json({ shareToken: token, expiresAt, hasPasscode: !!passHash });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`POST share (${outline ? 'outline' : 'study'}) error:`, err);
|
||||||
|
res.status(500).json({ error: 'Failed to enable sharing.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.post('/api/projects/:id/share', requireAuth, (req, res) => saveLink(req, res, false));
|
||||||
|
|
||||||
|
// DELETE /api/projects/:id/share — revoke the share link
|
||||||
|
app.delete('/api/projects/:id/share', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
clearShareToken(req.params.id, req.session.userId);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('DELETE /api/projects/:id/share error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to revoke sharing.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Co-teachers: other accounts that may edit a study. The owner makes a single-use invite link; whoever
|
||||||
|
// opens it while signed in joins. The owner (or the co-teacher themself) can remove an editor.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
app.get('/api/projects/:id/collaborators', requireAuth, (req, res) => {
|
||||||
|
if (!isProjectOwner(req.params.id, req.session.userId)) return res.status(404).json({ error: 'Project not found.' });
|
||||||
|
res.json({ collaborators: listCollaborators(req.params.id), pendingInvites: countOpenEditInvites(req.params.id) });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/projects/:id/collaborators/invite', requireAuth, (req, res) => {
|
||||||
|
if (!isProjectOwner(req.params.id, req.session.userId)) return res.status(404).json({ error: 'Project not found.' });
|
||||||
|
const token = randomUUID().replace(/-/g, '');
|
||||||
|
const expiresAt = Date.now() + 7 * 86_400_000;
|
||||||
|
createEditInvite(token, req.params.id, expiresAt);
|
||||||
|
res.json({ token, expiresAt });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/projects/:id/collaborators/:userId', requireAuth, (req, res) => {
|
||||||
|
const me = req.session.userId;
|
||||||
|
if (!isProjectOwner(req.params.id, me) && req.params.userId !== me) return res.status(403).json({ error: 'Only the owner can remove someone else.' });
|
||||||
|
removeCollaborator(req.params.id, req.params.userId);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/join/:token', requireAuth, (req, res) => {
|
||||||
|
const result = acceptEditInvite(req.params.token, req.session.userId);
|
||||||
|
if (result.error) return res.status(400).json({ error: result.error });
|
||||||
|
res.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read-only outline links. A separate token from the study share link, and the public endpoint returns
|
||||||
|
// only the outlines (see outlineShare.js), never the notes or scripts.
|
||||||
|
app.get('/api/projects/:id/outline-share', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const settings = getShareSettings(req.params.id, req.session.userId, true);
|
||||||
|
res.json({ shareToken: settings?.token ?? null, expiresAt: settings?.expiresAt ?? null, hasPasscode: !!settings?.hasPasscode });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('GET /api/projects/:id/outline-share error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to load share status.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/projects/:id/outline-share', requireAuth, (req, res) => saveLink(req, res, true));
|
||||||
|
|
||||||
|
app.delete('/api/projects/:id/outline-share', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
clearOutlineShareToken(req.params.id, req.session.userId);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('DELETE /api/projects/:id/outline-share error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to revoke sharing.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// A wrong passcode counts as a failed attempt for that link and address
|
||||||
|
async function openSharedLink(req, res, outline) {
|
||||||
|
const found = getSharedByToken(req.params.token, outline);
|
||||||
|
if (!found) { res.status(404).json({ error: outline ? 'This outline link is invalid or has been turned off.' : 'This share link is invalid or has been revoked.' }); return null; }
|
||||||
|
const keys = [`ip:${req.ip}`, `share:${req.params.token}`];
|
||||||
|
if (isLimited(...keys)) { res.status(429).json(TOO_MANY); return null; }
|
||||||
|
const access = await checkShareAccess({ expiresAt: found.expiresAt, passHash: found.passHash, supplied: req.get('X-Share-Passcode') });
|
||||||
|
if (!access.ok) {
|
||||||
|
if (access.needsPasscode && req.get('X-Share-Passcode')) recordFailure(...keys);
|
||||||
|
res.status(access.status).json({ error: access.error, needsPasscode: !!access.needsPasscode, expired: access.status === 410 });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return found.project;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/share-outline/:token — PUBLIC, no login required (a passcode may be needed)
|
||||||
|
app.get('/api/share-outline/:token', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const project = await openSharedLink(req, res, true);
|
||||||
|
if (project) res.json(buildOutlinePayload(project));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('GET /api/share-outline/:token error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to load the shared outline.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/share/:token — PUBLIC, no login required: fetch a shared project read-only (a passcode may be needed)
|
||||||
|
app.get('/api/share/:token', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const project = await openSharedLink(req, res, false);
|
||||||
|
if (project) res.json(project);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('GET /api/share/:token error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to load shared project.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { isAdminEmail } from './auth.js';
|
||||||
|
import { votdAvailable } from './votd.js';
|
||||||
|
import { listPasskeys } from './db.js';
|
||||||
|
|
||||||
|
// Shape returned to the client for the signed-in user (login, MFA verify, /auth/me).
|
||||||
|
export 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,
|
||||||
|
votdEnabled: !!user.votdEnabled, votdTime: user.votdTime ?? null, votdAvailable: votdAvailable(),
|
||||||
|
prayerEnabled: !!user.prayerEnabled, prayerTime: user.prayerTime ?? null,
|
||||||
|
loginAlerts: !!user.loginAlerts, passkeyCount: listPasskeys(user.id).length,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// Home: how your weekly recalls have gone — the last eight weeks at a glance, then every recall you've written.
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useApp } from '../context/AppContext.js';
|
||||||
|
import { loadProjectById } from '../utils/projectStorage.js';
|
||||||
|
import { recallHistory, recallSummary } from '../utils/recall.js';
|
||||||
|
|
||||||
|
const LABEL = { solid: 'Had it', partly: 'Partly', forgot: 'Slipped' };
|
||||||
|
const DOT = { solid: 'bg-[var(--r-accent)] border-[var(--r-accent)]', partly: 'bg-[var(--r-hl)] border-[var(--r-accent)]', forgot: 'border-[var(--r-sub)]' };
|
||||||
|
|
||||||
|
export default function RecallHistory() {
|
||||||
|
const { projectIndex, openStudyChunk } = useApp();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const history = useMemo(() => recallHistory(projectIndex.map((e) => loadProjectById(e.id)).filter(Boolean)), [projectIndex]);
|
||||||
|
if (history.length === 0) return null;
|
||||||
|
const { counts, total, recent } = recallSummary(history);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Recall history" className="mb-4 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-3 text-sm">
|
||||||
|
<button type="button" onClick={() => setOpen((o) => !o)} aria-expanded={open} className="flex w-full flex-wrap items-center justify-between gap-3 text-left">
|
||||||
|
<span><span className="font-medium">Recall history</span> <span className="text-xs text-[var(--r-sub)]">{total} recall{total === 1 ? '' : 's'} · {counts.solid} had it · {counts.partly} partly · {counts.forgot} slipped</span></span>
|
||||||
|
<span className="flex items-center gap-1" aria-label="Last eight weeks">
|
||||||
|
{recent.map((w) => (
|
||||||
|
<span key={w.start} title={w.rating ? LABEL[w.rating] : 'No recall that week'}
|
||||||
|
className={`h-3 w-3 rounded-full border ${w.rating ? DOT[w.rating] : 'border-[var(--r-line)]'}`} />
|
||||||
|
))}
|
||||||
|
<span className="ml-1 text-xs text-[var(--r-sub)]">{open ? '▾' : '▸'}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<ul className="mt-3 space-y-2.5">
|
||||||
|
{history.map((h) => (
|
||||||
|
<li key={`${h.chunkId}-${h.at}`} className="rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] p-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 text-xs">
|
||||||
|
<span><span className="font-medium text-sm">{h.reference}</span> <span className="text-[var(--r-sub)]">· {h.projectTitle} · {new Date(h.at).toLocaleDateString()}</span></span>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="rounded-full border border-[var(--r-line)] px-2 py-0.5">{LABEL[h.rating] ?? '—'}</span>
|
||||||
|
<button type="button" onClick={() => openStudyChunk(h.projectId, h.chunkId)} className="text-[var(--r-accent)] hover:underline">Open</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{h.text && <p className="mt-1.5 line-clamp-3 whitespace-pre-line font-serif text-[13px] leading-relaxed text-[var(--r-sub)]">{h.text}</p>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -43,7 +43,7 @@ export function ToastProvider({ children }) {
|
|||||||
return (
|
return (
|
||||||
<ToastContext.Provider value={value}>
|
<ToastContext.Provider value={value}>
|
||||||
{children}
|
{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">
|
<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)+var(--bottom-nav,0px))] font-sans">
|
||||||
{toasts.map((t) => (
|
{toasts.map((t) => (
|
||||||
<div key={t.id} role={t.tone === 'error' ? 'alert' : 'status'}
|
<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)]'}`}>
|
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)]'}`}>
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// Home card: once a week, write what you remember of a passage you studied, then compare it with your notes.
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useApp } from '../context/AppContext.js';
|
||||||
|
import { loadProjectById } from '../utils/projectStorage.js';
|
||||||
|
import { pickRecall } from '../utils/recall.js';
|
||||||
|
|
||||||
|
const RATE = [
|
||||||
|
{ id: 'solid', label: 'I had it' },
|
||||||
|
{ id: 'partly', label: 'Partly' },
|
||||||
|
{ id: 'forgot', label: 'Most of it slipped' },
|
||||||
|
];
|
||||||
|
const box = 'mb-4 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-3 text-sm';
|
||||||
|
const noteLabels = [['observation', 'Observe'], ['interpretation', 'Interpret'], ['application', 'Apply']];
|
||||||
|
|
||||||
|
export default function WeeklyRecall() {
|
||||||
|
const { projectIndex, saveChunkRecall, openStudyChunk } = useApp();
|
||||||
|
const [step, setStep] = useState('card'); // card -> write -> compare -> done
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
const pick = useMemo(
|
||||||
|
() => pickRecall(projectIndex.map((e) => loadProjectById(e.id)).filter(Boolean)),
|
||||||
|
[projectIndex],
|
||||||
|
);
|
||||||
|
if (step === 'done') return <div role="status" className={box}>Recall saved. See you next week.</div>;
|
||||||
|
if (!pick) return null;
|
||||||
|
|
||||||
|
const finish = (rating) => {
|
||||||
|
saveChunkRecall(pick.projectId, pick.chunk.id, { at: Date.now(), text: text.trim(), rating });
|
||||||
|
setStep('done');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (step === 'card') {
|
||||||
|
return (
|
||||||
|
<div className={`${box} flex flex-wrap items-center justify-between gap-3`}>
|
||||||
|
<span><span className="font-medium">Weekly recall:</span> {pick.reference} <span className="text-[var(--r-sub)]">· {pick.projectTitle}</span></span>
|
||||||
|
<button type="button" onClick={() => setStep('write')} className="rounded-full bg-[var(--r-text)] px-3 py-1 text-xs font-medium text-[var(--r-bg)]">Start</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<section aria-label="Weekly recall" className={box}>
|
||||||
|
<p className="font-medium">{pick.reference} <span className="font-normal text-[var(--r-sub)]">· {pick.projectTitle}</span></p>
|
||||||
|
{step === 'write' ? (
|
||||||
|
<>
|
||||||
|
<p className="mt-0.5 text-xs text-[var(--r-sub)]">From memory, no peeking: what did this passage say, what did it mean, and what did it ask of you?</p>
|
||||||
|
<textarea aria-label="What you remember" value={text} onChange={(e) => setText(e.target.value)} rows={6}
|
||||||
|
className="mt-2 w-full resize-y rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 font-serif text-[15px] leading-7 focus:outline-none" />
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<button type="button" disabled={!text.trim()} onClick={() => setStep('compare')} className="rounded-full bg-[var(--r-text)] px-3 py-1 text-xs font-medium text-[var(--r-bg)] disabled:opacity-40">Show my notes</button>
|
||||||
|
<button type="button" onClick={() => { setText(''); setStep('card'); }} className="text-xs text-[var(--r-sub)] hover:underline">Not now</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mt-2 grid gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">What you remembered</p>
|
||||||
|
<p className="whitespace-pre-line font-serif text-[13px] leading-relaxed">{text}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Your notes</p>
|
||||||
|
{noteLabels.map(([f, label]) => pick.chunk[f]?.trim() && (
|
||||||
|
<p key={f} className="mb-1.5 whitespace-pre-line font-serif text-[13px] leading-relaxed"><span className="font-sans text-xs font-medium">{label}: </span>{pick.chunk[f]}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-xs text-[var(--r-sub)]">How did you do?</p>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-2">
|
||||||
|
{RATE.map((r) => (
|
||||||
|
<button key={r.id} type="button" onClick={() => finish(r.id)} className="rounded-full border border-[var(--r-text)] px-3 py-1 text-xs hover:bg-black/5">{r.label}</button>
|
||||||
|
))}
|
||||||
|
<button type="button" onClick={() => openStudyChunk(pick.projectId, pick.chunk.id)} className="text-xs text-[var(--r-accent)] hover:underline">Open this chunk to review</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,26 +1,30 @@
|
|||||||
// Home card: today's verse and Spurgeon's Morning & Evening reading, both from IQ Bible.
|
// Home card: today's verse and Spurgeon's Morning & Evening reading, both from IQ Bible.
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useApp } from '../../context/AppContext.js';
|
import { useApp } from '../../context/AppContext.js';
|
||||||
import { useIq, useIqAvailable } from '../../hooks/useIq.js';
|
import { useIq, useIqStatus } from '../../hooks/useIq.js';
|
||||||
import VerseShareImage, { BACKGROUND_KEY } from '../VerseShareImage.jsx';
|
import VerseShareImage, { BACKGROUND_KEY } from '../VerseShareImage.jsx';
|
||||||
import { SCENES, loadPhotoList, resolveBackground, sceneDataUrl } from '../../utils/verseBackgrounds.js';
|
import { SCENES, loadPhotoList, resolveBackground, sceneDataUrl } from '../../utils/verseBackgrounds.js';
|
||||||
import { bookOptions } from '../../utils/bibleUtils.jsx';
|
import { bookOptions } from '../../utils/bibleUtils.jsx';
|
||||||
import { READER_TRANSLATIONS, fetchChapter } from '../../utils/bibleText.js';
|
import { READER_TRANSLATIONS, fetchChapter } from '../../utils/bibleText.js';
|
||||||
|
import { fallbackVerseFor } from '../../utils/fallbackVerses.js';
|
||||||
|
|
||||||
export default function IqDailyCard() {
|
export default function IqDailyCard() {
|
||||||
const on = useIqAvailable();
|
const status = useIqStatus();
|
||||||
if (!on) return null;
|
if (status === null) return null;
|
||||||
return <Card />;
|
return <Card iq={status} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Card() {
|
function Card({ iq }) {
|
||||||
const { jumpToReaderVerse, openBibleReader, readerTranslation, readerTheme } = useApp();
|
const { jumpToReaderVerse, openBibleReader, readerTranslation, readerTheme } = useApp();
|
||||||
// Today's verse is chosen by IQ Bible (the same for everyone); its words come from the Bible you pick.
|
// Today's verse is chosen by IQ Bible (the same for everyone); its words come from the Bible you pick.
|
||||||
const votd = useIq('bibles/eng_kjv/verse-of-the-day');
|
const votd = useIq(iq ? 'bibles/eng_kjv/verse-of-the-day' : null);
|
||||||
|
// When IQ Bible can't give us a verse (down, no key, allowance used up) the card shows a built-in one in the
|
||||||
|
// reader's own translation instead of disappearing.
|
||||||
|
const fallback = !votd.data?.data && votd.status !== 'loading';
|
||||||
const [chosen, setChosen] = useState(() => { try { return localStorage.getItem('votd-translation') || ''; } catch { return ''; } });
|
const [chosen, setChosen] = useState(() => { try { return localStorage.getItem('votd-translation') || ''; } catch { return ''; } });
|
||||||
const translation = READER_TRANSLATIONS.some((t) => t.id === chosen) ? chosen : (readerTranslation || 'BSB');
|
const translation = !fallback && READER_TRANSLATIONS.some((t) => t.id === chosen) ? chosen : (readerTranslation || 'BSB');
|
||||||
const pick = (id) => { setChosen(id); try { localStorage.setItem('votd-translation', id); } catch { /* storage unavailable */ } };
|
const pick = (id) => { setChosen(id); try { localStorage.setItem('votd-translation', id); } catch { /* storage unavailable */ } };
|
||||||
const ref = votd.data?.data;
|
const ref = votd.data?.data ?? (fallback ? fallbackVerseFor() : undefined);
|
||||||
const [text, setText] = useState({ key: '', value: '', error: false });
|
const [text, setText] = useState({ key: '', value: '', error: false });
|
||||||
const [sharing, setSharing] = useState(false);
|
const [sharing, setSharing] = useState(false);
|
||||||
// The picture behind the verse: a different one each day by default, or the one you chose.
|
// The picture behind the verse: a different one each day by default, or the one you chose.
|
||||||
@@ -42,15 +46,16 @@ function Card() {
|
|||||||
.catch(() => { if (!controller.signal.aborted) setText({ key, value: '', error: true }); });
|
.catch(() => { if (!controller.signal.aborted) setText({ key, value: '', error: true }); });
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [key]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [key]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
const dev = useIq('devotionals/today');
|
const dev = useIq(iq ? 'devotionals/today' : null);
|
||||||
const [part, setPart] = useState(() => (new Date().getHours() < 15 ? 'morning' : 'evening'));
|
const [part, setPart] = useState(() => (new Date().getHours() < 15 ? 'morning' : 'evening'));
|
||||||
const verse = votd.data?.data;
|
const verse = ref;
|
||||||
const reading = dev.data?.data?.[part];
|
const reading = dev.data?.data?.[part];
|
||||||
const loaded = text.key === key;
|
const loaded = text.key === key;
|
||||||
const shown = loaded && text.value ? text.value : loaded && text.error ? ref?.text ?? '' : '…';
|
const shown = loaded && text.value ? text.value : loaded && text.error ? ref?.text ?? '' : '…';
|
||||||
const shownTranslation = loaded && text.error ? 'KJV' : translation;
|
const shownTranslation = loaded && text.error ? 'KJV' : translation;
|
||||||
const name = (abbrev) => bookOptions.find((b) => b.abbrev === abbrev)?.name ?? abbrev;
|
const name = (abbrev) => bookOptions.find((b) => b.abbrev === abbrev)?.name ?? abbrev;
|
||||||
if (!verse && !reading) return null;
|
if (!verse && !reading) return null;
|
||||||
|
if (fallback && !reading && loaded && text.error) return null; // nothing to show, not even the built-in verse
|
||||||
return (
|
return (
|
||||||
<section className="mb-4 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Daily verse and devotional">
|
<section className="mb-4 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Daily verse and devotional">
|
||||||
{verse && (
|
{verse && (
|
||||||
@@ -65,10 +70,10 @@ function Card() {
|
|||||||
<optgroup label="Scenes">{SCENES.map((sc) => <option key={sc.id} value={sc.id}>{sc.name}</option>)}</optgroup>
|
<optgroup label="Scenes">{SCENES.map((sc) => <option key={sc.id} value={sc.id}>{sc.name}</option>)}</optgroup>
|
||||||
{photos.length > 0 && <optgroup label="Your photos">{photos.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}</optgroup>}
|
{photos.length > 0 && <optgroup label="Your photos">{photos.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}</optgroup>}
|
||||||
</select>
|
</select>
|
||||||
<select aria-label="Verse of the day translation" value={translation} onChange={(e) => pick(e.target.value)}
|
{!fallback && <select aria-label="Verse of the day translation" value={translation} onChange={(e) => pick(e.target.value)}
|
||||||
className="rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-0.5 text-xs">
|
className="rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-0.5 text-xs">
|
||||||
{READER_TRANSLATIONS.map((t) => <option key={t.id} value={t.id}>{t.id}</option>)}
|
{READER_TRANSLATIONS.map((t) => <option key={t.id} value={t.id}>{t.id}</option>)}
|
||||||
</select>
|
</select>}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* the translation's text; the KJV from IQ Bible is the fallback if that Bible can't be loaded right now */}
|
{/* the translation's text; the KJV from IQ Bible is the fallback if that Bible can't be loaded right now */}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
// <option>s for IQ Bible's narrated Bibles, to add inside an audio narrator <select>. Playing a
|
// <option>s for IQ Bible's narrated Bibles, to add inside an audio narrator <select>. Playing a
|
||||||
// chapter this way uses one of the account's monthly audio streams, so the group says so.
|
// chapter this way uses one of the account's monthly audio streams, so the group says so.
|
||||||
|
import { useState } from 'react';
|
||||||
import { useIq, useIqAvailable } from '../../hooks/useIq.js';
|
import { useIq, useIqAvailable } from '../../hooks/useIq.js';
|
||||||
import { listOf } from '../../utils/iqbible.js';
|
import { listOf } from '../../utils/iqbible.js';
|
||||||
|
|
||||||
export default function IqNarratorOptions() {
|
// `enabled` holds the request back until someone opens the list (or an IQ narration is already chosen).
|
||||||
|
export default function IqNarratorOptions({ enabled = true }) {
|
||||||
const on = useIqAvailable();
|
const on = useIqAvailable();
|
||||||
const { data } = useIq(on ? 'audio' : null);
|
const { data } = useIq(on && enabled ? 'audio' : null);
|
||||||
const sets = listOf(data).filter((s) => s.active !== false);
|
const sets = listOf(data).filter((s) => s.active !== false);
|
||||||
if (!on || sets.length === 0) return null;
|
if (!on || sets.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
@@ -14,3 +16,10 @@ export default function IqNarratorOptions() {
|
|||||||
</optgroup>
|
</optgroup>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For the <select> that holds these options: props to spread on it, and the `enabled` flag to pass along.
|
||||||
|
export function useLazyNarrators(value) {
|
||||||
|
const [opened, setOpened] = useState(false);
|
||||||
|
const open = () => setOpened(true);
|
||||||
|
return { enabled: opened || String(value ?? '').startsWith('iq:'), selectProps: { onFocus: open, onMouseDown: open, onTouchStart: open } };
|
||||||
|
}
|
||||||
@@ -160,6 +160,20 @@ describe('IqDailyCard', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('IqDailyCard fallback', () => {
|
||||||
|
test('when IQ Bible is down, shows the built-in verse of the day in the reader translation, with no picker', async () => {
|
||||||
|
const { default: IqDailyCard } = await import('./IqDailyCard.jsx');
|
||||||
|
const { fallbackVerseFor } = await import('../../utils/fallbackVerses.js');
|
||||||
|
const v = fallbackVerseFor();
|
||||||
|
routes['/api/iq/bibles/eng_kjv/verse-of-the-day'] = { status: 502, body: { error: 'down' } };
|
||||||
|
routes[`https://bible.helloao.org/api/BSB/${v.book_usfm}/${v.chapter_number}.json`] = { chapter: { content: [{ type: 'verse', number: v.verse_number, content: ['Built-in verse text'] }] } };
|
||||||
|
const { AppContext } = await import('../../context/AppContext.js');
|
||||||
|
render(<AppContext.Provider value={{ jumpToReaderVerse: vi.fn(), openBibleReader: vi.fn(), readerTranslation: 'BSB' }}><IqDailyCard /></AppContext.Provider>);
|
||||||
|
expect(await screen.findByText('Built-in verse text')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByLabelText('Verse of the day translation')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Study side', () => {
|
describe('Study side', () => {
|
||||||
const chunk = { id: 'c1', startVerse: 14, endVerse: 16, tags: ['love'], generalNotes: '' };
|
const chunk = { id: 'c1', startVerse: 14, endVerse: 16, tags: ['love'], generalNotes: '' };
|
||||||
const chapter = { book: 'John', bookAbbrev: 'JHN', chapter: '3' };
|
const chapter = { book: 'John', bookAbbrev: 'JHN', chapter: '3' };
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { HomeIcon } from './icons.jsx';
|
||||||
|
|
||||||
|
// The way back to the dashboard from the Reader and from a study. Always labelled, and big enough to hit with a thumb.
|
||||||
|
export default function HomeButton({ onClick }) {
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={onClick} aria-label="Back to home" data-tip="Back to home"
|
||||||
|
className="flex h-10 shrink-0 items-center gap-1.5 rounded-full border border-[var(--r-line)] px-3 text-sm text-[var(--r-text)] transition hover:bg-black/5">
|
||||||
|
<HomeIcon /><span>Home</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// Top and bottom chrome for the reader. Everything stays one tap away; focus
|
// Top and bottom chrome for the reader. Everything stays one tap away; focus
|
||||||
// mode hides both.
|
// mode hides both.
|
||||||
import { ChevronLeft, ChevronRight, ChevronDown, SearchIcon, LinkIcon, PenIcon, HeadphonesIcon, FocusIcon, CheckIcon, CompareIcon, HighlighterIcon } from './icons.jsx';
|
import { ChevronLeft, ChevronRight, ChevronDown, SearchIcon, LinkIcon, PenIcon, HeadphonesIcon, FocusIcon, CheckIcon, CompareIcon, HighlighterIcon } from './icons.jsx';
|
||||||
|
import HomeButton from './HomeButton.jsx';
|
||||||
import { BookmarkIcon } from '../../utils/bibleUtils.jsx';
|
import { BookmarkIcon } from '../../utils/bibleUtils.jsx';
|
||||||
|
|
||||||
const iconBtn = 'flex h-9 w-9 items-center justify-center rounded-lg text-[var(--r-sub)] transition hover:bg-black/5 disabled:opacity-30 disabled:hover:bg-transparent';
|
const iconBtn = 'flex h-9 w-9 items-center justify-center rounded-lg text-[var(--r-sub)] transition hover:bg-black/5 disabled:opacity-30 disabled:hover:bg-transparent';
|
||||||
@@ -9,7 +10,7 @@ export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks,
|
|||||||
return (
|
return (
|
||||||
<div className="safe-top sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)] font-sans">
|
<div className="safe-top sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)] font-sans">
|
||||||
<div className="mx-auto flex h-12 max-w-4xl items-center gap-1 px-2">
|
<div className="mx-auto flex h-12 max-w-4xl items-center gap-1 px-2">
|
||||||
<button type="button" onClick={onBack} className="flex h-9 items-center gap-0.5 rounded-lg pl-1 pr-2 text-[var(--r-sub)] transition hover:bg-black/5" aria-label="Back to home" data-tip="Back to home"><ChevronLeft /><span className="hidden text-xs sm:inline">Home</span></button>
|
<HomeButton onClick={onBack} />
|
||||||
<button type="button" onClick={onOpenNavigator}
|
<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" data-tip="Go to a book and chapter (G)">
|
className="mx-auto flex min-w-0 items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium text-[var(--r-text)] hover:bg-black/5" data-tip="Go to a book and chapter (G)">
|
||||||
<span className="truncate">{title}</span><ChevronDown />
|
<span className="truncate">{title}</span><ChevronDown />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
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 };
|
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 ChevronLeft = () => <svg {...base}><path d="M12 4l-6 6 6 6" /></svg>;
|
||||||
|
export const HomeIcon = () => <svg {...base}><path d="M3.5 9.5L10 4l6.5 5.5M5 8.5V16h3.5v-4h3v4H15V8.5" /></svg>;
|
||||||
export const ChevronRight = () => <svg {...base}><path d="M8 4l6 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 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 SearchIcon = () => <svg {...base}><circle cx="9" cy="9" r="5.5" /><path d="M13.5 13.5L17 17" /></svg>;
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
// Settings → Preferences: the choices that used to be reachable only from the screen they affect
|
||||||
|
// (highlight colour names, the dashboard's layout, the verse of the day, saved study templates).
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useApp } from '../../context/AppContext.js';
|
||||||
|
import { useLocalSetting } from '../../hooks/useLocalSetting.js';
|
||||||
|
import { useIqAvailable } from '../../hooks/useIq.js';
|
||||||
|
import { READER_TRANSLATIONS } from '../../utils/bibleText.js';
|
||||||
|
import { COLOR_NAMES, COLOR_SUGGESTIONS, HIGHLIGHT_COLORS } from '../../utils/highlightColors.js';
|
||||||
|
import { SCENES, loadPhotoList } from '../../utils/verseBackgrounds.js';
|
||||||
|
import { BACKGROUND_KEY } from '../VerseShareImage.jsx';
|
||||||
|
|
||||||
|
const card = 'rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6';
|
||||||
|
const field = 'rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
|
||||||
|
|
||||||
|
function Row({ label, hint, children }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1.5 border-t border-[var(--r-line)] py-3 first:border-t-0">
|
||||||
|
<div className="min-w-0 flex-1 basis-48">
|
||||||
|
<p className="text-sm">{label}</p>
|
||||||
|
{hint && <p className="text-xs text-[var(--r-sub)]">{hint}</p>}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HighlightColours() {
|
||||||
|
const { readerColorLabels = {}, setReaderColorLabel } = useApp();
|
||||||
|
return (
|
||||||
|
<section aria-label="Highlight colours" className={card}>
|
||||||
|
<h2 className="text-base font-medium">Highlight colours</h2>
|
||||||
|
<p className="mb-3 mt-1 text-sm text-[var(--r-sub)]">Give each colour a meaning, such as “Promise” or “Command”. The names show in your Highlights list and exports, and sync to your other devices.</p>
|
||||||
|
{HIGHLIGHT_COLORS.map((c) => (
|
||||||
|
<div key={c} className="flex items-center gap-3 border-t border-[var(--r-line)] py-2 first:border-t-0">
|
||||||
|
<span aria-hidden="true" className="h-5 w-5 shrink-0 rounded-full border border-black/10" style={{ background: c }} />
|
||||||
|
<input aria-label={`Name for ${COLOR_NAMES[c].toLowerCase()} highlights`} value={readerColorLabels[c] ?? ''} maxLength={30}
|
||||||
|
onChange={(e) => setReaderColorLabel(c, e.target.value)} placeholder={COLOR_SUGGESTIONS[c] ?? COLOR_NAMES[c]} className={`${field} min-w-0 flex-1`} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardSettings() {
|
||||||
|
const [view, setView] = useLocalSetting('homeView', 'grid');
|
||||||
|
const [sort, setSort] = useLocalSetting('homeSort', 'recent');
|
||||||
|
return (
|
||||||
|
<section aria-label="My Studies page" className={card}>
|
||||||
|
<h2 className="text-base font-medium">My Studies page</h2>
|
||||||
|
<Row label="Show studies as" hint="You can still switch on the page itself.">
|
||||||
|
<div role="group" aria-label="Show studies as" className="flex overflow-hidden rounded-xl border border-[var(--r-line)]">
|
||||||
|
{[['grid', 'Grid'], ['list', 'List']].map(([id, text]) => (
|
||||||
|
<button key={id} type="button" aria-pressed={view === id} onClick={() => setView(id)}
|
||||||
|
className={`px-3 py-2 text-sm ${view === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{text}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
<Row label="Sort by">
|
||||||
|
<select aria-label="Sort studies by" value={sort} onChange={(e) => setSort(e.target.value)} className={field}>
|
||||||
|
<option value="recent">Last edited</option>
|
||||||
|
<option value="title">Title</option>
|
||||||
|
<option value="passage">Passage</option>
|
||||||
|
</select>
|
||||||
|
</Row>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VerseOfDaySettings() {
|
||||||
|
const on = useIqAvailable();
|
||||||
|
const { readerTranslation } = useApp();
|
||||||
|
const [translation, setTranslation] = useLocalSetting('votd-translation', '');
|
||||||
|
const [background, setBackground] = useLocalSetting(BACKGROUND_KEY, 'auto');
|
||||||
|
const [photos, setPhotos] = useState([]);
|
||||||
|
useEffect(() => { let live = true; loadPhotoList().then((list) => { if (live) setPhotos(list); }); return () => { live = false; }; }, []);
|
||||||
|
if (!on) return null;
|
||||||
|
const shown = READER_TRANSLATIONS.some((t) => t.id === translation) ? translation : '';
|
||||||
|
return (
|
||||||
|
<section aria-label="Verse of the day" className={card}>
|
||||||
|
<h2 className="text-base font-medium">Verse of the day</h2>
|
||||||
|
<p className="mb-1 mt-1 text-sm text-[var(--r-sub)]">The card on your My Studies page. Turn on the daily reminder under Notifications.</p>
|
||||||
|
<Row label="Bible" hint="Today’s verse is chosen for everyone; its words come from the Bible you pick.">
|
||||||
|
<select aria-label="Verse of the day Bible" value={shown} onChange={(e) => setTranslation(e.target.value)} className={field}>
|
||||||
|
<option value="">Same as my default Bible ({readerTranslation})</option>
|
||||||
|
{READER_TRANSLATIONS.map((t) => <option key={t.id} value={t.id}>{t.id} · {t.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</Row>
|
||||||
|
<Row label="Picture behind the verse">
|
||||||
|
<select aria-label="Verse of the day picture" value={background} onChange={(e) => setBackground(e.target.value)} className={field}>
|
||||||
|
<option value="auto">A different picture each day</option>
|
||||||
|
<option value="plain">Plain</option>
|
||||||
|
<optgroup label="Scenes">{SCENES.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}</optgroup>
|
||||||
|
{photos.length > 0 && <optgroup label="Your photos">{photos.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}</optgroup>}
|
||||||
|
</select>
|
||||||
|
</Row>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StudyTemplates() {
|
||||||
|
const { userTemplates = [], deleteUserTemplate } = useApp();
|
||||||
|
return (
|
||||||
|
<section aria-label="Study templates" className={card}>
|
||||||
|
<h2 className="text-base font-medium">Study templates</h2>
|
||||||
|
<p className="mb-2 mt-1 text-sm text-[var(--r-sub)]">Templates you saved from a study’s Setup stage. They sync to your other devices.</p>
|
||||||
|
{userTemplates.length === 0 ? (
|
||||||
|
<p className="text-sm text-[var(--r-sub)]">None saved yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-[var(--r-line)]">
|
||||||
|
{userTemplates.map((t) => (
|
||||||
|
<li key={t.id} className="flex items-center justify-between gap-3 py-2">
|
||||||
|
<span className="min-w-0"><span className="text-sm">{t.name}</span>{t.description && <span className="block truncate text-xs text-[var(--r-sub)]">{t.description}</span>}</span>
|
||||||
|
<button type="button" onClick={() => { if (window.confirm(`Delete the template “${t.name}”? Studies already made from it keep their notes.`)) deleteUserTemplate(t.id); }}
|
||||||
|
className="shrink-0 rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5">Delete</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
// area for background notes and tags so the page stays quiet.
|
// area for background notes and tags so the page stays quiet.
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import DictateButton, { appendSpoken } from '../DictateButton.jsx';
|
import DictateButton, { appendSpoken } from '../DictateButton.jsx';
|
||||||
|
import { ownShare, ownWords, pastedWords, withPaste, clearPastes } from '../../utils/ownWork.js';
|
||||||
import { templateFromChunk, templatePrompt } from '../../utils/studyTemplates.js';
|
import { templateFromChunk, templatePrompt } from '../../utils/studyTemplates.js';
|
||||||
|
|
||||||
const SECTIONS = [
|
const SECTIONS = [
|
||||||
@@ -36,11 +37,23 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
|
|||||||
};
|
};
|
||||||
const suggestions = allTags.filter((t) => !tags.some((x) => x.toLowerCase() === t.toLowerCase()));
|
const suggestions = allTags.filter((t) => !tags.some((x) => x.toLowerCase() === t.toLowerCase()));
|
||||||
|
|
||||||
|
const share = ownShare(chunk);
|
||||||
|
const keepPaste = (field) => (e) => {
|
||||||
|
const next = withPaste(chunk, field, e.clipboardData?.getData('text'));
|
||||||
|
if (next) updateChunk(chunk.id, { aiPastes: next });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-label="Study notes" className="space-y-2">
|
<section aria-label="Study notes" className="space-y-2">
|
||||||
|
{share !== null && (
|
||||||
|
<p className="px-1 text-xs text-[var(--r-sub)]" title="Long pastes into a note box count as pasted, not your own">
|
||||||
|
{share === 100 ? 'All of these notes are your own words.' : `${share}% of these notes are your own words.`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{SECTIONS.map(({ field, label, hint, placeholder }) => {
|
{SECTIONS.map(({ field, label, hint, placeholder }) => {
|
||||||
const isOpen = open === field;
|
const isOpen = open === field;
|
||||||
const n = words(chunk[field]);
|
const n = words(chunk[field]);
|
||||||
|
const pasted = pastedWords(chunk, field);
|
||||||
return (
|
return (
|
||||||
<div key={field} className={`overflow-hidden rounded-2xl border ${isOpen ? 'border-[var(--r-accent)]' : 'border-[var(--r-line)]'}`}>
|
<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}
|
<button type="button" onClick={() => setOpen(isOpen ? null : field)} aria-expanded={isOpen}
|
||||||
@@ -50,12 +63,23 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
|
|||||||
<span className="text-sm font-medium">{label}</span>
|
<span className="text-sm font-medium">{label}</span>
|
||||||
<span className="text-xs text-[var(--r-sub)]">{hint}</span>
|
<span className="text-xs text-[var(--r-sub)]">{hint}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-[var(--r-sub)]">{n ? `${n} word${n === 1 ? '' : 's'}` : ''}</span>
|
<span className="flex items-center gap-2 text-xs text-[var(--r-sub)]">
|
||||||
|
{n > 0 && (pasted > 0
|
||||||
|
? <span className="rounded-full bg-[var(--r-hl)] px-2 py-0.5 text-[var(--r-text)]">{ownWords(chunk, field) === 0 ? 'Pasted' : `${pasted} pasted`}</span>
|
||||||
|
: <span className="rounded-full border border-[var(--r-line)] px-2 py-0.5">Mine</span>)}
|
||||||
|
{n ? `${n} word${n === 1 ? '' : 's'}` : ''}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="px-4 pb-4">
|
<div className="px-4 pb-4">
|
||||||
<textarea aria-label={label} value={chunk[field] ?? ''} rows={7} placeholder={templatePrompt(templateId, field) ?? placeholder}
|
<textarea aria-label={label} value={chunk[field] ?? ''} rows={7} placeholder={templatePrompt(templateId, field) ?? placeholder}
|
||||||
onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })} className={areaCls} />
|
onPaste={keepPaste(field)} onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })} className={areaCls} />
|
||||||
|
{pasted > 0 && (
|
||||||
|
<p className="mt-1.5 text-xs text-[var(--r-sub)]">
|
||||||
|
{pasted} word{pasted === 1 ? '' : 's'} here were pasted, so they don't count as your own work.{' '}
|
||||||
|
<button type="button" onClick={() => updateChunk(chunk.id, { aiPastes: clearPastes(chunk, field) })} className="text-[var(--r-accent)] hover:underline">That’s mine</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="mt-1.5"><DictateButton label={`Dictate ${label.toLowerCase()}`} onText={(said) => updateChunk(chunk.id, { [field]: appendSpoken(chunk[field], said) })} /></div>
|
<div className="mt-1.5"><DictateButton label={`Dictate ${label.toLowerCase()}`} onText={(said) => updateChunk(chunk.id, { [field]: appendSpoken(chunk[field], said) })} /></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
import { useApp } from '../../context/AppContext.js';
|
import { useApp } from '../../context/AppContext.js';
|
||||||
import { themeVars, READER_THEMES } from '../reader/readerThemes.js';
|
import { themeVars, READER_THEMES } from '../reader/readerThemes.js';
|
||||||
import { ChevronLeft, SearchIcon } from '../reader/icons.jsx';
|
import { ChevronLeft, SearchIcon } from '../reader/icons.jsx';
|
||||||
|
import { useIsPhone } from '../../hooks/useIsPhone.js';
|
||||||
|
|
||||||
// `short` is what a phone shows; the full label is still the button's accessible name.
|
// `short` is what a phone shows; the full label is still the button's accessible name.
|
||||||
const NAV = [
|
const NAV = [
|
||||||
@@ -13,8 +14,18 @@ const NAV = [
|
|||||||
{ id: 'prayer', label: 'Prayer', short: 'Prayer' },
|
{ id: 'prayer', label: 'Prayer', short: 'Prayer' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const ICONS = {
|
||||||
|
studies: 'M4 5.5A1.5 1.5 0 0 1 5.5 4H18a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1H6a2 2 0 0 1-2-2V5.5ZM8 4v15M11 8h5M11 11.5h5',
|
||||||
|
reader: 'M12 6.5C10.5 5 8 4.5 4.5 4.5V18c3.5 0 6 .5 7.5 2 1.5-1.5 4-2 7.5-2V4.5c-3.5 0-6 .5-7.5 2ZM12 6.5V20',
|
||||||
|
plans: 'M5 6.5h14M5 6.5v12A1.5 1.5 0 0 0 6.5 20h11a1.5 1.5 0 0 0 1.5-1.5v-12M5 6.5A1.5 1.5 0 0 1 6.5 5H8m11 1.5A1.5 1.5 0 0 0 17.5 5H16M8 3.5v3m8-3v3M9 12.5l2 2 4-4',
|
||||||
|
prayer: 'M12 20s-7-4.4-7-10a4 4 0 0 1 7-2.6A4 4 0 0 1 19 10c0 5.6-7 10-7 10Z',
|
||||||
|
};
|
||||||
|
const TabIcon = ({ id }) => (
|
||||||
|
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={ICONS[id]} /></svg>
|
||||||
|
);
|
||||||
|
|
||||||
// Avatar button with a dropdown: who you are, the theme, Settings, Admin, Log out.
|
// Avatar button with a dropdown: who you are, the theme, Settings, Admin, Log out.
|
||||||
function AccountMenu() {
|
function AccountMenu({ tab = false, active = false }) {
|
||||||
const { authUser, readerTheme, setReaderTheme, setCurrentPage, logout } = useApp();
|
const { authUser, readerTheme, setReaderTheme, setCurrentPage, logout } = useApp();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const ref = useRef(null);
|
const ref = useRef(null);
|
||||||
@@ -33,12 +44,20 @@ function AccountMenu() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className="relative">
|
<div ref={ref} className="relative">
|
||||||
<button type="button" onClick={() => setOpen((o) => !o)} aria-label="Account menu" aria-expanded={open}
|
{tab ? (
|
||||||
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">
|
<button type="button" onClick={() => setOpen((o) => !o)} aria-label="Account menu" aria-expanded={open}
|
||||||
{authUser?.email?.[0] ?? '·'}
|
className={`flex min-h-[52px] w-full flex-col items-center justify-center gap-0.5 text-[11px] ${open || active ? 'text-[var(--r-text)]' : 'text-[var(--r-sub)]'}`}>
|
||||||
</button>
|
<span className="flex h-[22px] w-[22px] items-center justify-center rounded-full border border-current text-[11px] font-medium uppercase">{authUser?.email?.[0] ?? '·'}</span>
|
||||||
|
<span aria-hidden="true">Account</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<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 && (
|
{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">
|
<div role="menu" className={`absolute z-40 w-60 ${tab ? 'bottom-full right-2 mb-2' : 'right-0 top-full mt-2'} 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>}
|
{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">
|
<div className="px-3 pb-2 pt-1">
|
||||||
<p className="mb-1.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Theme</p>
|
<p className="mb-1.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Theme</p>
|
||||||
@@ -65,23 +84,30 @@ function AccountMenu() {
|
|||||||
export default function PageShell({ title, onBack, actions, active = null, maxWidth = 'max-w-6xl', children }) {
|
export default function PageShell({ title, onBack, actions, active = null, maxWidth = 'max-w-6xl', children }) {
|
||||||
const { readerTheme, goHome, goStudies, openBibleReader, openReadingPlans, openPrayer, openPalette } = useApp();
|
const { readerTheme, goHome, goStudies, openBibleReader, openReadingPlans, openPrayer, openPalette } = useApp();
|
||||||
const go = { studies: goStudies, reader: openBibleReader, plans: openReadingPlans, prayer: openPrayer };
|
const go = { studies: goStudies, reader: openBibleReader, plans: openReadingPlans, prayer: openPrayer };
|
||||||
|
const phone = useIsPhone();
|
||||||
|
// On a phone the destinations sit in a bar at the bottom, where a thumb reaches; toasts lift above it.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!phone) return undefined;
|
||||||
|
document.documentElement.style.setProperty('--bottom-nav', '56px');
|
||||||
|
return () => document.documentElement.style.removeProperty('--bottom-nav');
|
||||||
|
}, [phone]);
|
||||||
return (
|
return (
|
||||||
<div className="min-h-dvh bg-[var(--r-bg)] font-sans text-[var(--r-text)]" style={themeVars(readerTheme)}>
|
<div className="min-h-dvh bg-[var(--r-bg)] font-sans text-[var(--r-text)]" style={themeVars(readerTheme)}>
|
||||||
<a href="#content" className="skip-link">Skip to content</a>
|
<a href="#content" className="skip-link">Skip to content</a>
|
||||||
<header className="safe-top sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)]">
|
<header className="safe-top sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)]">
|
||||||
<div className={`mx-auto flex h-12 ${maxWidth} items-center gap-2 px-3 sm:gap-4 sm:px-4`}>
|
<div className="mx-auto flex h-12 max-w-6xl items-center gap-2 px-3 sm:gap-4 sm:px-4">
|
||||||
<button type="button" onClick={goHome} className="-m-2 flex shrink-0 items-center gap-2 p-2" aria-label="Bible Study home">
|
<button type="button" onClick={goHome} className="-m-2 flex shrink-0 items-center gap-2 p-2" aria-label="Bible Study home">
|
||||||
<img src="/logo.svg" alt="" className="h-6 w-6" />
|
<img src="/logo.svg" alt="" className="h-6 w-6" />
|
||||||
<span className="hidden text-sm font-medium sm:inline">Bible Study</span>
|
<span className="hidden text-sm font-medium sm:inline">Bible Study</span>
|
||||||
</button>
|
</button>
|
||||||
<nav aria-label="Main" className="flex items-center gap-0.5">
|
{!phone && <nav aria-label="Main" className="flex items-center gap-0.5">
|
||||||
{NAV.map((n) => (
|
{NAV.map((n) => (
|
||||||
<button key={n.id} type="button" onClick={go[n.id]} aria-current={active === n.id ? 'page' : undefined} aria-label={n.label}
|
<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'}`}>
|
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>
|
<span className="sm:hidden" aria-hidden="true">{n.short}</span><span className="hidden sm:inline" aria-hidden="true">{n.label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>}
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
{openPalette && (
|
{openPalette && (
|
||||||
<button type="button" onClick={openPalette} aria-label="Search and go to" data-tip="Search studies, notes and passages (⌘K)"
|
<button type="button" onClick={openPalette} aria-label="Search and go to" data-tip="Search studies, notes and passages (⌘K)"
|
||||||
@@ -89,7 +115,7 @@ export default function PageShell({ title, onBack, actions, active = null, maxWi
|
|||||||
<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>
|
<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>
|
</button>
|
||||||
)}
|
)}
|
||||||
<AccountMenu />
|
{!phone && <AccountMenu />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -103,6 +129,21 @@ export default function PageShell({ title, onBack, actions, active = null, maxWi
|
|||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
{phone && (
|
||||||
|
<>
|
||||||
|
<div aria-hidden="true" className="h-[calc(5rem+env(safe-area-inset-bottom,0px))]" />
|
||||||
|
<nav aria-label="Main" className="fixed inset-x-0 bottom-0 z-30 grid grid-cols-5 border-t border-[var(--r-line)] bg-[var(--r-bar)] pb-[env(safe-area-inset-bottom)]">
|
||||||
|
{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={`flex min-h-[52px] flex-col items-center justify-center gap-0.5 text-[11px] ${active === n.id ? 'text-[var(--r-text)]' : 'text-[var(--r-sub)]'}`}>
|
||||||
|
<TabIcon id={n.id} />
|
||||||
|
<span aria-hidden="true" className={active === n.id ? 'font-medium' : ''}>{n.short}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<AccountMenu tab active={['settings', 'admin'].includes(active)} />
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||||
|
import { render, screen, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
|
const app = {
|
||||||
|
readerTheme: 'paper', authUser: { email: 'sam@x.test' }, setReaderTheme: vi.fn(), setCurrentPage: vi.fn(), logout: vi.fn(),
|
||||||
|
goHome: vi.fn(), goStudies: vi.fn(), openBibleReader: vi.fn(), openReadingPlans: vi.fn(), openPrayer: vi.fn(), openPalette: vi.fn(),
|
||||||
|
};
|
||||||
|
vi.mock('../../context/AppContext.js', () => ({ useApp: () => app }));
|
||||||
|
import PageShell from './PageShell.jsx';
|
||||||
|
|
||||||
|
const phone = (on) => vi.stubGlobal('matchMedia', (q) => ({ matches: on && q.includes('639'), media: q, addEventListener() {}, removeEventListener() {} }));
|
||||||
|
beforeEach(() => Object.values(app).forEach((f) => f.mockClear?.()));
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
test('on a phone the destinations are a bar at the bottom, with Account in it', async () => {
|
||||||
|
phone(true);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<PageShell title="Prayer journal" active="prayer"><p>content</p></PageShell>);
|
||||||
|
const nav = screen.getByRole('navigation', { name: 'Main' });
|
||||||
|
expect(within(nav).getAllByRole('button').map((b) => b.getAttribute('aria-label'))).toEqual(['Studies', 'Read Bible', 'Reading Plans', 'Prayer', 'Account menu']);
|
||||||
|
expect(within(nav).getByRole('button', { name: 'Prayer' })).toHaveAttribute('aria-current', 'page');
|
||||||
|
expect(screen.getAllByRole('button', { name: 'Account menu' })).toHaveLength(1); // not also in the header
|
||||||
|
await user.click(within(nav).getByRole('button', { name: 'Reading Plans' }));
|
||||||
|
expect(app.openReadingPlans).toHaveBeenCalled();
|
||||||
|
await user.click(within(nav).getByRole('button', { name: 'Account menu' }));
|
||||||
|
await user.click(screen.getByRole('menuitem', { name: 'Settings' }));
|
||||||
|
expect(app.setCurrentPage).toHaveBeenCalledWith('settings');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('on a wide screen the destinations stay in the header', () => {
|
||||||
|
phone(false);
|
||||||
|
render(<PageShell title="Prayer journal" active="prayer"><p>content</p></PageShell>);
|
||||||
|
const nav = screen.getByRole('navigation', { name: 'Main' });
|
||||||
|
expect(nav.closest('header')).not.toBeNull();
|
||||||
|
expect(screen.getAllByRole('button', { name: 'Account menu' })).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the header is the same width on every page, whatever width the page content is', () => {
|
||||||
|
phone(false);
|
||||||
|
const { container, rerender } = render(<PageShell title="A" maxWidth="max-w-3xl"><p>x</p></PageShell>);
|
||||||
|
const inner = () => container.querySelector('header > div');
|
||||||
|
expect(inner().className).toContain('max-w-6xl');
|
||||||
|
rerender(<PageShell title="B" maxWidth="max-w-2xl"><p>x</p></PageShell>);
|
||||||
|
expect(inner().className).toContain('max-w-6xl');
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
// the stage switcher, and save/sync status.
|
// the stage switcher, and save/sync status.
|
||||||
import { useApp } from '../../context/AppContext.js';
|
import { useApp } from '../../context/AppContext.js';
|
||||||
import { themeVars, READER_THEMES } from '../reader/readerThemes.js';
|
import { themeVars, READER_THEMES } from '../reader/readerThemes.js';
|
||||||
import { ChevronLeft } from '../reader/icons.jsx';
|
import HomeButton from '../reader/HomeButton.jsx';
|
||||||
|
|
||||||
export const STAGES = [
|
export const STAGES = [
|
||||||
{ id: 'setup', label: 'Chunks' },
|
{ id: 'setup', label: 'Chunks' },
|
||||||
@@ -27,8 +27,7 @@ export default function StudioShell({ stage, loading = false, fixedHeight = fals
|
|||||||
<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={`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="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">
|
<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"
|
<HomeButton onClick={goHome} />
|
||||||
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>
|
<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">
|
<nav aria-label="Study stages" className="mx-auto flex gap-0.5">
|
||||||
{STAGES.map((s) => {
|
{STAGES.map((s) => {
|
||||||
|
|||||||
+6
-1
@@ -3,7 +3,12 @@ import { iqAvailable, iqGet } from '../utils/iqbible.js';
|
|||||||
|
|
||||||
// Whether the server has an IQ Bible key. false until known, so IQ-only UI never flashes up.
|
// Whether the server has an IQ Bible key. false until known, so IQ-only UI never flashes up.
|
||||||
export function useIqAvailable() {
|
export function useIqAvailable() {
|
||||||
const [on, setOn] = useState(false);
|
return useIqStatus() === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same, but null until the server has answered.
|
||||||
|
export function useIqStatus() {
|
||||||
|
const [on, setOn] = useState(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let live = true;
|
let live = true;
|
||||||
iqAvailable().then((v) => { if (live) setOn(v); });
|
iqAvailable().then((v) => { if (live) setOn(v); });
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const QUERY = '(max-width: 639px)';
|
||||||
|
const read = () => (typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? window.matchMedia(QUERY).matches : false);
|
||||||
|
|
||||||
|
// True on a phone-width screen (under Tailwind's `sm`). False where matchMedia is missing (tests), so the full
|
||||||
|
// layout is what renders there.
|
||||||
|
export function useIsPhone() {
|
||||||
|
const [phone, setPhone] = useState(read);
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window.matchMedia !== 'function') return undefined;
|
||||||
|
const mq = window.matchMedia(QUERY);
|
||||||
|
const update = () => setPhone(mq.matches);
|
||||||
|
update();
|
||||||
|
mq.addEventListener?.('change', update);
|
||||||
|
return () => mq.removeEventListener?.('change', update);
|
||||||
|
}, []);
|
||||||
|
return phone;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const EVENT = 'local-setting';
|
||||||
|
|
||||||
|
// A preference kept in this browser's localStorage, for settings that live in more than one place (the Settings
|
||||||
|
// page and the screen they affect). Changing it in one place updates every component that reads it.
|
||||||
|
export function useLocalSetting(key, fallback) {
|
||||||
|
const read = () => { try { return localStorage.getItem(key) ?? fallback; } catch { return fallback; } };
|
||||||
|
const [value, setValue] = useState(read);
|
||||||
|
useEffect(() => {
|
||||||
|
const on = (e) => { if (!e.detail || e.detail.key === key) setValue(read()); };
|
||||||
|
window.addEventListener(EVENT, on);
|
||||||
|
window.addEventListener('storage', on);
|
||||||
|
return () => { window.removeEventListener(EVENT, on); window.removeEventListener('storage', on); };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [key]);
|
||||||
|
const set = (next) => {
|
||||||
|
try { localStorage.setItem(key, next); } catch { /* storage unavailable: still applies until reload */ }
|
||||||
|
setValue(next);
|
||||||
|
window.dispatchEvent(new CustomEvent(EVENT, { detail: { key } }));
|
||||||
|
};
|
||||||
|
return [value, set];
|
||||||
|
}
|
||||||
@@ -221,6 +221,32 @@ export function useProjectLibrary({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Saves a weekly recall (what you remembered, and how it went) on one chunk of a stored study.
|
||||||
|
const saveChunkRecall = (id, chunkId, recall) => {
|
||||||
|
const loaded = loadProjectById(id);
|
||||||
|
if (!loaded) return;
|
||||||
|
const updated = {
|
||||||
|
...loaded,
|
||||||
|
lastEdited: Date.now(),
|
||||||
|
chapters: loaded.chapters.map((ch) => ({
|
||||||
|
...ch,
|
||||||
|
chunks: ch.chunks.map((c) => (c.id === chunkId ? { ...c, recalls: [...(c.recalls ?? []), recall] } : c)),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
saveProjectToStorage(updated);
|
||||||
|
unsyncedStudies.add(id);
|
||||||
|
setProjectIndex(loadProjectIndex());
|
||||||
|
if (project?.id === id) setProject(updated);
|
||||||
|
saveRemoteProject(updated, { baseEdited: getSyncBase(id) }).then((result) => {
|
||||||
|
if (result.ok) {
|
||||||
|
const serverEdited = result.data?.lastEdited ?? updated.lastEdited;
|
||||||
|
setSyncBase(id, serverEdited);
|
||||||
|
unsyncedStudies.remove(id);
|
||||||
|
try { saveProjectToStorage({ ...updated, lastEdited: serverEdited }); setProjectIndex(loadProjectIndex()); } catch { /* already saved */ }
|
||||||
|
} else if (result.status === 409) toast('That study changed on another device. Open it to choose which version to keep.', { tone: 'error' });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Deleting is immediate here, with an Undo for a few seconds; only after that is the server copy
|
// Deleting is immediate here, with an Undo for a few seconds; only after that is the server copy
|
||||||
// removed (and straight away if the tab is closed first, so a deleted project never comes back).
|
// removed (and straight away if the tab is closed first, so a deleted project never comes back).
|
||||||
const finishDelete = (id) => {
|
const finishDelete = (id) => {
|
||||||
@@ -287,6 +313,7 @@ export function useProjectLibrary({
|
|||||||
openStudyChunk,
|
openStudyChunk,
|
||||||
renameProjectInStorage,
|
renameProjectInStorage,
|
||||||
setProjectSeries,
|
setProjectSeries,
|
||||||
|
saveChunkRecall,
|
||||||
deleteProject,
|
deleteProject,
|
||||||
pullLatestFromServer,
|
pullLatestFromServer,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ select {
|
|||||||
/* iPhone and iPad: don't inflate text when the phone is turned sideways, no grey flash on tap, and no
|
/* iPhone and iPad: don't inflate text when the phone is turned sideways, no grey flash on tap, and no
|
||||||
double-tap-to-zoom delay on controls. */
|
double-tap-to-zoom delay on controls. */
|
||||||
html {
|
html {
|
||||||
|
scrollbar-gutter: stable; /* pages with and without a scrollbar line up the same */
|
||||||
-webkit-text-size-adjust: 100%;
|
-webkit-text-size-adjust: 100%;
|
||||||
text-size-adjust: 100%;
|
text-size-adjust: 100%;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export default function AdminPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell title="Admin" onBack={goHome} maxWidth="max-w-5xl">
|
<PageShell title="Admin" active="admin" onBack={goHome} maxWidth="max-w-5xl">
|
||||||
<main className="mx-auto max-w-5xl space-y-5 px-4 py-6">
|
<main className="mx-auto max-w-5xl space-y-5 px-4 py-6">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button type="button" onClick={() => setAdminTab('users')}
|
<button type="button" onClick={() => setAdminTab('users')}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
import { useApp } from '../context/AppContext.js';
|
import { useApp } from '../context/AppContext.js';
|
||||||
import { filterStudies, groupBySeries, isTagQuery, seriesList, tagCounts } from '../utils/studyFilter.js';
|
import { filterStudies, groupBySeries, isTagQuery, seriesList, tagCounts } from '../utils/studyFilter.js';
|
||||||
import BookCover from '../components/BookCover.jsx';
|
import BookCover from '../components/BookCover.jsx';
|
||||||
|
import WeeklyRecall from '../components/WeeklyRecall.jsx';
|
||||||
|
import RecallHistory from '../components/RecallHistory.jsx';
|
||||||
import IqDailyCard from '../components/iq/IqDailyCard.jsx';
|
import IqDailyCard from '../components/iq/IqDailyCard.jsx';
|
||||||
import IqNarratorOptions from '../components/iq/IqNarratorOptions.jsx';
|
import IqNarratorOptions, { useLazyNarrators } from '../components/iq/IqNarratorOptions.jsx';
|
||||||
import PageShell from '../components/study/PageShell.jsx';
|
import PageShell from '../components/study/PageShell.jsx';
|
||||||
import AudioPlayer from '../components/AudioPlayer.jsx';
|
import AudioPlayer from '../components/AudioPlayer.jsx';
|
||||||
import {
|
import {
|
||||||
@@ -49,6 +51,7 @@ export default function HomePage() {
|
|||||||
handleSeekAudio,
|
handleSeekAudio,
|
||||||
handleSeekAudioTo,
|
handleSeekAudioTo,
|
||||||
} = useApp();
|
} = useApp();
|
||||||
|
const narrators = useLazyNarrators(audioNarrator);
|
||||||
const [homeSearch, setHomeSearch] = useState('');
|
const [homeSearch, setHomeSearch] = useState('');
|
||||||
const [homeSort, setHomeSort] = useState(() => {
|
const [homeSort, setHomeSort] = useState(() => {
|
||||||
try { return localStorage.getItem('homeSort') || 'recent'; } catch { return 'recent'; }
|
try { return localStorage.getItem('homeSort') || 'recent'; } catch { return 'recent'; }
|
||||||
@@ -134,6 +137,8 @@ export default function HomePage() {
|
|||||||
>
|
>
|
||||||
<main className="mx-auto grid max-w-6xl gap-6 px-4 py-6 lg:grid-cols-[minmax(0,1fr)_21rem]">
|
<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">
|
<section className="min-w-0">
|
||||||
|
<WeeklyRecall />
|
||||||
|
<RecallHistory />
|
||||||
<IqDailyCard />
|
<IqDailyCard />
|
||||||
{autoRestoredCount !== null && (
|
{autoRestoredCount !== null && (
|
||||||
<div className={notice}>Synced {autoRestoredCount} project{autoRestoredCount > 1 ? 's' : ''} from another device.</div>
|
<div className={notice}>Synced {autoRestoredCount} project{autoRestoredCount > 1 ? 's' : ''} from another device.</div>
|
||||||
@@ -343,11 +348,11 @@ export default function HomePage() {
|
|||||||
<select value={audioBook} onChange={(e) => setAudioBook(e.target.value)} disabled={audioState.status === 'playing' || audioState.status === 'paused'} className={fieldCls}>
|
<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>)}
|
{bookOptions.map((book) => <option key={book.abbrev} value={book.abbrev}>{book.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<select value={audioNarrator} onChange={(e) => setAudioNarrator(e.target.value)} disabled={audioState.status === 'playing' || audioState.status === 'paused'} className={fieldCls}>
|
<select value={audioNarrator} onChange={(e) => setAudioNarrator(e.target.value)} disabled={audioState.status === 'playing' || audioState.status === 'paused'} className={fieldCls} {...narrators.selectProps}>
|
||||||
<option value="david">David</option>
|
<option value="david">David</option>
|
||||||
<option value="hays">Hays</option>
|
<option value="hays">Hays</option>
|
||||||
<option value="souer">Souer</option>
|
<option value="souer">Souer</option>
|
||||||
<IqNarratorOptions />
|
<IqNarratorOptions enabled={narrators.enabled} />
|
||||||
</select>
|
</select>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useToast } from '../components/Toasts.jsx';
|
|||||||
import StudioShell from '../components/study/StudioShell.jsx';
|
import StudioShell from '../components/study/StudioShell.jsx';
|
||||||
import { formatChunkReference } from '../utils/bibleUtils.jsx';
|
import { formatChunkReference } from '../utils/bibleUtils.jsx';
|
||||||
import { buildClaudePrompt, buildPodcastPrompt } from '../utils/exportBuilders.js';
|
import { buildClaudePrompt, buildPodcastPrompt } from '../utils/exportBuilders.js';
|
||||||
|
import { chunksNeedingWork } from '../utils/ownWork.js';
|
||||||
import { buildScriptDraft, spokenMinutes, wordCount } from '../utils/scriptDraft.js';
|
import { buildScriptDraft, spokenMinutes, wordCount } from '../utils/scriptDraft.js';
|
||||||
|
|
||||||
const has = (s) => (s ?? '').trim().length > 0;
|
const has = (s) => (s ?? '').trim().length > 0;
|
||||||
@@ -23,6 +24,7 @@ export default function ScriptPage() {
|
|||||||
const iq = useIqAvailable();
|
const iq = useIqAvailable();
|
||||||
const [gathering, setGathering] = useState(false);
|
const [gathering, setGathering] = useState(false);
|
||||||
const [presenting, setPresenting] = useState(false);
|
const [presenting, setPresenting] = useState(false);
|
||||||
|
const [heldAction, setHeldAction] = useState(null); // an AI hand-off waiting on the own-work check
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const chunk = allChunks.find((c) => c.id === project?.selectedChunkId) ?? allChunks[0] ?? null;
|
const chunk = allChunks.find((c) => c.id === project?.selectedChunkId) ?? allChunks[0] ?? null;
|
||||||
@@ -54,6 +56,11 @@ export default function ScriptPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// AI hand-offs only go straight through once every chunk has your own notes; otherwise ask first.
|
||||||
|
const needingWork = chunksNeedingWork(allChunks);
|
||||||
|
const guarded = (action) => () => { if (needingWork.length === 0) action(); else setHeldAction(() => action); };
|
||||||
|
const goStudy = (id) => { select(id); setHeldAction(null); setCurrentPage('study'); };
|
||||||
|
|
||||||
// Copies a prompt, then opens claude.ai so it can be pasted straight in.
|
// Copies a prompt, then opens claude.ai so it can be pasted straight in.
|
||||||
const copyPrompt = (prompt) => {
|
const copyPrompt = (prompt) => {
|
||||||
navigator.clipboard.writeText(prompt).then(() => {
|
navigator.clipboard.writeText(prompt).then(() => {
|
||||||
@@ -130,12 +137,12 @@ export default function ScriptPage() {
|
|||||||
<input type="file" accept=".docx" className="hidden" onChange={(e) => { importDocx(e.target.files?.[0]); e.target.value = ''; }} />
|
<input type="file" accept=".docx" className="hidden" onChange={(e) => { importDocx(e.target.files?.[0]); e.target.value = ''; }} />
|
||||||
</label>
|
</label>
|
||||||
<span className="mx-1 h-4 w-px bg-[var(--r-line)]" />
|
<span className="mx-1 h-4 w-px bg-[var(--r-line)]" />
|
||||||
<button type="button" onClick={() => copyPrompt(buildClaudePrompt(project))} className={btn}
|
<button type="button" onClick={guarded(() => copyPrompt(buildClaudePrompt(project)))} className={btn}
|
||||||
title="Copies your study notes as a Claude prompt, then opens claude.ai">Study guide → Claude ↗</button>
|
title="Copies your study notes as a Claude prompt, then opens claude.ai">Study guide → Claude ↗</button>
|
||||||
<button type="button" onClick={() => copyPrompt(buildPodcastPrompt(project, authUser?.podcastName))} className={btn}
|
<button type="button" onClick={guarded(() => copyPrompt(buildPodcastPrompt(project, authUser?.podcastName)))} className={btn}
|
||||||
title="Copies a podcast script prompt for Claude — set your show name in Account Settings first">Podcast → Claude ↗</button>
|
title="Copies a podcast script prompt for Claude — set your show name in Account Settings first">Podcast → Claude ↗</button>
|
||||||
{iq && (
|
{iq && (
|
||||||
<button type="button" disabled={gathering} onClick={copyPodcastWithContext} className={btn}
|
<button type="button" disabled={gathering} onClick={guarded(copyPodcastWithContext)} className={btn}
|
||||||
title={`Adds IQ Bible commentary, cross-references and word glosses for up to ${chunkRefs(project).refs.length} passages (each uses a few API requests)`}>
|
title={`Adds IQ Bible commentary, cross-references and word glosses for up to ${chunkRefs(project).refs.length} passages (each uses a few API requests)`}>
|
||||||
{gathering ? 'Gathering…' : 'Podcast + background → Claude ↗'}
|
{gathering ? 'Gathering…' : 'Podcast + background → Claude ↗'}
|
||||||
</button>
|
</button>
|
||||||
@@ -145,6 +152,25 @@ export default function ScriptPage() {
|
|||||||
<DictateButton label="Dictate script" onText={(said) => set({ finalScript: appendSpoken(script, said) })} />
|
<DictateButton label="Dictate script" onText={(said) => set({ finalScript: appendSpoken(script, said) })} />
|
||||||
{notice && <span role="status" className="text-xs text-[var(--r-accent)]">{notice}</span>}
|
{notice && <span role="status" className="text-xs text-[var(--r-accent)]">{notice}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
{heldAction && (
|
||||||
|
<div role="alert" className="rounded-xl border border-[var(--r-accent)] bg-[var(--r-hl)] p-3 text-sm">
|
||||||
|
<p className="font-medium">Is this your study, or Claude's?</p>
|
||||||
|
<p className="mt-0.5 text-xs text-[var(--r-sub)]">These chunks don't have much of your own thinking yet. Write it first, then let Claude polish it.</p>
|
||||||
|
<ul className="mt-2 space-y-1 text-xs">
|
||||||
|
{needingWork.map(({ chunk: c, missing }) => (
|
||||||
|
<li key={c.id} className="flex items-center justify-between gap-3">
|
||||||
|
<span>{formatChunkReference(project, project.chapters.findIndex((ch) => ch.chunks.some((x) => x.id === c.id)), c, '–')}: {missing.map((m) => `${m.label} ${m.have}/${m.need}`).join(', ')}</span>
|
||||||
|
<button type="button" onClick={() => goStudy(c.id)} className="text-[var(--r-accent)] hover:underline">Write it →</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="mt-2.5 flex gap-2">
|
||||||
|
<button type="button" onClick={() => goStudy(needingWork[0].chunk.id)} className="rounded-full bg-[var(--r-text)] px-3 py-1 text-xs font-medium text-[var(--r-bg)]">Go write my notes</button>
|
||||||
|
<button type="button" onClick={() => { const run = heldAction; setHeldAction(null); run(); }} className="rounded-full border border-[var(--r-text)] px-3 py-1 text-xs hover:bg-black/5">Send to Claude anyway</button>
|
||||||
|
<button type="button" onClick={() => setHeldAction(null)} className="text-xs text-[var(--r-sub)] hover:underline">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<textarea aria-label="Final script" value={script} onChange={(e) => set({ finalScript: e.target.value })}
|
<textarea aria-label="Final script" value={script} onChange={(e) => set({ finalScript: e.target.value })}
|
||||||
placeholder="Write or paste the final script here. “Build outline from notes” starts you from your study notes."
|
placeholder="Write or paste the final script here. “Build outline from notes” starts you from your study notes."
|
||||||
className="min-h-[24rem] flex-1 resize-y rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] px-5 py-4 font-serif text-[16px] leading-8 text-[var(--r-text)] placeholder:font-sans placeholder:text-sm placeholder:text-[var(--r-sub)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" />
|
className="min-h-[24rem] flex-1 resize-y rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] px-5 py-4 font-serif text-[16px] leading-8 text-[var(--r-text)] placeholder:font-sans placeholder:text-sm placeholder:text-[var(--r-sub)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" />
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { normalizeNtfyServer } from '../utils/ntfyServer.js';
|
import { normalizeNtfyServer } from '../utils/ntfyServer.js';
|
||||||
import IqNarratorOptions from '../components/iq/IqNarratorOptions.jsx';
|
import IqNarratorOptions, { useLazyNarrators } from '../components/iq/IqNarratorOptions.jsx';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useApp } from '../context/AppContext.js';
|
import { useApp } from '../context/AppContext.js';
|
||||||
import PageShell from '../components/study/PageShell.jsx';
|
import PageShell from '../components/study/PageShell.jsx';
|
||||||
import { READER_TRANSLATIONS } from '../utils/bibleText.js';
|
import { READER_TRANSLATIONS } from '../utils/bibleText.js';
|
||||||
import DataSection from '../components/settings/DataSection.jsx';
|
import DataSection from '../components/settings/DataSection.jsx';
|
||||||
|
import { DashboardSettings, HighlightColours, StudyTemplates, VerseOfDaySettings } from '../components/settings/MoreSettings.jsx';
|
||||||
|
import { useLocalSetting } from '../hooks/useLocalSetting.js';
|
||||||
|
import { useIqAvailable } from '../hooks/useIq.js';
|
||||||
import PasskeysSection from '../components/settings/PasskeysSection.jsx';
|
import PasskeysSection from '../components/settings/PasskeysSection.jsx';
|
||||||
import DevicesSection from '../components/settings/DevicesSection.jsx';
|
import DevicesSection from '../components/settings/DevicesSection.jsx';
|
||||||
import { COMMENTARIES } from '../hooks/useVerseStudy.js';
|
import { COMMENTARIES } from '../hooks/useVerseStudy.js';
|
||||||
@@ -61,10 +64,14 @@ export default function SettingsPage() {
|
|||||||
readerWide, setReaderWide,
|
readerWide, setReaderWide,
|
||||||
readerCommentary, setReaderCommentary,
|
readerCommentary, setReaderCommentary,
|
||||||
audioNarrator, setAudioNarrator,
|
audioNarrator, setAudioNarrator,
|
||||||
|
readerFont, setReaderFont, readerSpacing, setReaderSpacing, readerVerseNumbers, setReaderVerseNumbers,
|
||||||
settingsFocus, clearSettingsFocus,
|
settingsFocus, clearSettingsFocus,
|
||||||
} = useApp();
|
} = useApp();
|
||||||
|
|
||||||
// "Back up my data" from the ⌘K palette lands on that section
|
// "Back up my data" from the ⌘K palette lands on that section
|
||||||
|
const narrators = useLazyNarrators(audioNarrator);
|
||||||
|
const iqOn = useIqAvailable();
|
||||||
|
const [iqMode, setIqMode] = useLocalSetting('iq-commentary-mode', '0');
|
||||||
const [tab, setTab] = useState(settingsFocus === 'backup' ? 'data' : 'account');
|
const [tab, setTab] = useState(settingsFocus === 'backup' ? 'data' : 'account');
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!settingsFocus) return;
|
if (!settingsFocus) return;
|
||||||
@@ -234,7 +241,7 @@ export default function SettingsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell title="Account Settings" onBack={goHome} maxWidth="max-w-2xl">
|
<PageShell title="Account Settings" active="settings" onBack={goHome} maxWidth="max-w-2xl">
|
||||||
<main className="mx-auto max-w-2xl space-y-5 px-4 py-6">
|
<main className="mx-auto max-w-2xl space-y-5 px-4 py-6">
|
||||||
<div role="tablist" aria-label="Settings sections" className="-mx-1 flex gap-1 overflow-x-auto px-1 pb-1">
|
<div role="tablist" aria-label="Settings sections" className="-mx-1 flex gap-1 overflow-x-auto px-1 pb-1">
|
||||||
{SETTINGS_TABS.map(([id, label]) => (
|
{SETTINGS_TABS.map(([id, label]) => (
|
||||||
@@ -283,18 +290,32 @@ export default function SettingsPage() {
|
|||||||
{COMMENTARIES.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
{COMMENTARIES.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</PrefRow>
|
</PrefRow>
|
||||||
|
{iqOn && (
|
||||||
|
<PrefRow label="Commentary sources" hint="Classic is the short list. More sources adds IQ Bible’s many commentaries.">
|
||||||
|
<Segmented label="Commentary sources" options={[['0', 'Classic'], ['1', 'More sources']]} value={iqMode} onChange={setIqMode} />
|
||||||
|
</PrefRow>
|
||||||
|
)}
|
||||||
<PrefRow label="Text size" hint="Reader text.">
|
<PrefRow label="Text size" hint="Reader text.">
|
||||||
<Segmented label="Text size" options={FONT_SIZES.map(([l, n]) => [n, l])} value={readerFontSize} onChange={setReaderFontSize} />
|
<Segmented label="Text size" options={FONT_SIZES.map(([l, n]) => [n, l])} value={readerFontSize} onChange={setReaderFontSize} />
|
||||||
</PrefRow>
|
</PrefRow>
|
||||||
|
<PrefRow label="Typeface">
|
||||||
|
<Segmented label="Typeface" options={[['serif', 'Serif'], ['sans', 'Sans'], ['easy', 'Easy to read']]} value={readerFont} onChange={setReaderFont} />
|
||||||
|
</PrefRow>
|
||||||
|
<PrefRow label="Line spacing">
|
||||||
|
<Segmented label="Line spacing" options={[['snug', 'Snug'], ['normal', 'Normal'], ['airy', 'Airy']]} value={readerSpacing} onChange={setReaderSpacing} />
|
||||||
|
</PrefRow>
|
||||||
|
<PrefRow label="Verse numbers">
|
||||||
|
<Segmented label="Verse numbers" options={[[true, 'Show'], [false, 'Hide']]} value={readerVerseNumbers} onChange={setReaderVerseNumbers} />
|
||||||
|
</PrefRow>
|
||||||
<PrefRow label="Reading width" hint="One column, or two on a wide screen.">
|
<PrefRow label="Reading width" hint="One column, or two on a wide screen.">
|
||||||
<Segmented label="Reading width" options={[[false, 'Column'], [true, 'Two columns']]} value={readerWide} onChange={setReaderWide} />
|
<Segmented label="Reading width" options={[[false, 'Column'], [true, 'Two columns']]} value={readerWide} onChange={setReaderWide} />
|
||||||
</PrefRow>
|
</PrefRow>
|
||||||
<PrefRow label="Audio narrator" hint="For the Berean Standard Bible audio. IQ Bible narrations count against its monthly audio allowance.">
|
<PrefRow label="Audio narrator" hint="For the Berean Standard Bible audio. IQ Bible narrations count against its monthly audio allowance.">
|
||||||
<select aria-label="Audio narrator" value={audioNarrator} onChange={(e) => setAudioNarrator(e.target.value)} className={selectCls}>
|
<select aria-label="Audio narrator" value={audioNarrator} onChange={(e) => setAudioNarrator(e.target.value)} className={selectCls} {...narrators.selectProps}>
|
||||||
<option value="david">David</option>
|
<option value="david">David</option>
|
||||||
<option value="hays">Hays</option>
|
<option value="hays">Hays</option>
|
||||||
<option value="souer">Souer</option>
|
<option value="souer">Souer</option>
|
||||||
<IqNarratorOptions />
|
<IqNarratorOptions enabled={narrators.enabled} />
|
||||||
</select>
|
</select>
|
||||||
</PrefRow>
|
</PrefRow>
|
||||||
</section>
|
</section>
|
||||||
@@ -388,6 +409,10 @@ export default function SettingsPage() {
|
|||||||
<DataSection />
|
<DataSection />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{tab === 'prefs' && <HighlightColours />}
|
||||||
|
{tab === 'prefs' && <DashboardSettings />}
|
||||||
|
{tab === 'prefs' && <VerseOfDaySettings />}
|
||||||
|
{tab === 'prefs' && <StudyTemplates />}
|
||||||
{tab === 'prefs' && (
|
{tab === 'prefs' && (
|
||||||
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-4">
|
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -83,6 +83,31 @@ describe('Authentication', () => {
|
|||||||
expect(await screen.findByDisplayValue('My Show')).toBeInTheDocument();
|
expect(await screen.findByDisplayValue('My Show')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('every reading and display preference can be set from Settings', async () => {
|
||||||
|
stubApi({ session: user });
|
||||||
|
const u = userEvent.setup();
|
||||||
|
render(<App />);
|
||||||
|
await openAccountMenu(u);
|
||||||
|
await u.click(await screen.findByRole('menuitem', { name: /settings/i }));
|
||||||
|
await u.click(await screen.findByRole('tab', { name: 'Preferences' }));
|
||||||
|
for (const name of ['Text size', 'Typeface', 'Line spacing', 'Verse numbers', 'Reading width', 'Verse layout', 'Theme', 'Home page']) {
|
||||||
|
expect(screen.getByRole('group', { name })).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
await u.click(within(screen.getByRole('group', { name: 'Typeface' })).getByRole('button', { name: 'Sans' }));
|
||||||
|
expect(localStorage.getItem('reader-font')).toBe('sans');
|
||||||
|
await u.click(within(screen.getByRole('group', { name: 'Verse numbers' })).getByRole('button', { name: 'Hide' }));
|
||||||
|
expect(localStorage.getItem('reader-verse-numbers')).toBe('off');
|
||||||
|
// highlight colour names
|
||||||
|
await u.type(screen.getByLabelText('Name for yellow highlights'), 'Promise');
|
||||||
|
expect(JSON.parse(localStorage.getItem('reader-color-labels'))['#fef08a']).toBe('Promise');
|
||||||
|
// the My Studies page
|
||||||
|
await u.click(within(screen.getByRole('group', { name: 'Show studies as' })).getByRole('button', { name: 'List' }));
|
||||||
|
expect(localStorage.getItem('homeView')).toBe('list');
|
||||||
|
await u.selectOptions(screen.getByLabelText('Sort studies by'), 'title');
|
||||||
|
expect(localStorage.getItem('homeSort')).toBe('title');
|
||||||
|
expect(screen.getByRole('heading', { name: 'Study templates' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
test('Settings is split into tabs, one group at a time', async () => {
|
test('Settings is split into tabs, one group at a time', async () => {
|
||||||
stubApi({ session: user });
|
stubApi({ session: user });
|
||||||
const u = userEvent.setup();
|
const u = userEvent.setup();
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// Used for the home card's verse of the day when IQ Bible can't be reached: one verse a day, the same for
|
||||||
|
// everyone, stepping through this list by the day of the year. [book USFM, chapter, verse].
|
||||||
|
export const FALLBACK_VERSES = [
|
||||||
|
['GEN', 1, 1], ['GEN', 1, 27], ['GEN', 12, 2], ['GEN', 28, 15], ['GEN', 50, 20],
|
||||||
|
['EXO', 14, 14], ['EXO', 15, 2], ['EXO', 33, 14], ['NUM', 6, 24], ['DEU', 6, 5],
|
||||||
|
['DEU', 31, 6], ['JOS', 1, 9], ['1SA', 16, 7], ['2CH', 7, 14], ['NEH', 8, 10],
|
||||||
|
['JOB', 19, 25], ['PSA', 1, 2], ['PSA', 16, 11], ['PSA', 19, 14], ['PSA', 23, 1],
|
||||||
|
['PSA', 23, 4], ['PSA', 27, 1], ['PSA', 27, 14], ['PSA', 28, 7], ['PSA', 30, 5],
|
||||||
|
['PSA', 34, 8], ['PSA', 34, 18], ['PSA', 37, 4], ['PSA', 46, 1], ['PSA', 46, 10],
|
||||||
|
['PSA', 51, 10], ['PSA', 55, 22], ['PSA', 62, 1], ['PSA', 91, 1], ['PSA', 100, 4],
|
||||||
|
['PSA', 103, 2], ['PSA', 118, 24], ['PSA', 119, 11], ['PSA', 119, 105], ['PSA', 121, 1],
|
||||||
|
['PSA', 121, 2], ['PSA', 139, 14], ['PSA', 145, 18], ['PRO', 3, 5], ['PRO', 3, 6],
|
||||||
|
['PRO', 4, 23], ['PRO', 16, 3], ['PRO', 18, 10], ['PRO', 22, 6], ['ECC', 3, 1],
|
||||||
|
['ISA', 26, 3], ['ISA', 40, 8], ['ISA', 40, 31], ['ISA', 41, 10], ['ISA', 43, 2],
|
||||||
|
['ISA', 53, 5], ['ISA', 55, 8], ['JER', 17, 7], ['JER', 29, 11], ['JER', 33, 3],
|
||||||
|
['LAM', 3, 22], ['EZK', 36, 26], ['DAN', 3, 17], ['MIC', 6, 8], ['HAB', 3, 17],
|
||||||
|
['ZEP', 3, 17], ['MAL', 3, 6], ['MAT', 5, 3], ['MAT', 5, 14], ['MAT', 5, 16],
|
||||||
|
['MAT', 6, 33], ['MAT', 7, 7], ['MAT', 11, 28], ['MAT', 19, 26], ['MAT', 22, 37],
|
||||||
|
['MAT', 28, 19], ['MAT', 28, 20], ['MRK', 10, 45], ['MRK', 12, 30], ['LUK', 1, 37],
|
||||||
|
['LUK', 6, 31], ['LUK', 11, 9], ['LUK', 19, 10], ['JHN', 1, 1], ['JHN', 1, 14],
|
||||||
|
['JHN', 3, 16], ['JHN', 6, 35], ['JHN', 8, 12], ['JHN', 8, 32], ['JHN', 10, 10],
|
||||||
|
['JHN', 11, 25], ['JHN', 13, 34], ['JHN', 14, 6], ['JHN', 14, 27], ['JHN', 15, 5],
|
||||||
|
['JHN', 16, 33], ['ACT', 1, 8], ['ACT', 4, 12], ['ROM', 1, 16], ['ROM', 3, 23],
|
||||||
|
['ROM', 5, 8], ['ROM', 6, 23], ['ROM', 8, 1], ['ROM', 8, 28], ['ROM', 8, 31],
|
||||||
|
['ROM', 8, 38], ['ROM', 10, 9], ['ROM', 12, 2], ['ROM', 15, 13], ['1CO', 10, 13],
|
||||||
|
['1CO', 13, 4], ['1CO', 13, 13], ['1CO', 15, 58], ['2CO', 5, 17], ['2CO', 12, 9],
|
||||||
|
['GAL', 2, 20], ['GAL', 5, 22], ['GAL', 6, 9], ['EPH', 2, 8], ['EPH', 2, 10],
|
||||||
|
['EPH', 3, 20], ['EPH', 4, 32], ['EPH', 6, 10], ['PHP', 1, 6], ['PHP', 4, 4],
|
||||||
|
['PHP', 4, 6], ['PHP', 4, 7], ['PHP', 4, 13], ['COL', 3, 2], ['COL', 3, 23],
|
||||||
|
['1TH', 5, 16], ['2TI', 1, 7], ['2TI', 3, 16], ['TIT', 3, 5], ['HEB', 4, 12],
|
||||||
|
['HEB', 4, 16], ['HEB', 11, 1], ['HEB', 11, 6], ['HEB', 12, 1], ['HEB', 13, 8],
|
||||||
|
['JAS', 1, 5], ['JAS', 1, 22], ['JAS', 4, 8], ['1PE', 5, 7], ['2PE', 3, 9],
|
||||||
|
['1JN', 1, 9], ['1JN', 4, 8], ['1JN', 4, 19], ['REV', 3, 20], ['REV', 21, 4],
|
||||||
|
];
|
||||||
|
|
||||||
|
// The verse for a calendar day (UTC, like IQ Bible's own pick, so everyone sees the same one).
|
||||||
|
export function fallbackVerseFor(date = new Date()) {
|
||||||
|
const day = Math.floor((Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()) - Date.UTC(date.getUTCFullYear(), 0, 0)) / 86400000);
|
||||||
|
const [book_usfm, chapter_number, verse_number] = FALLBACK_VERSES[day % FALLBACK_VERSES.length];
|
||||||
|
return { book_usfm, chapter_number, verse_number };
|
||||||
|
}
|
||||||
+16
-4
@@ -2,7 +2,12 @@
|
|||||||
// asked for; here we only build requests and keep answers for a few minutes so one open panel doesn't
|
// asked for; here we only build requests and keep answers for a few minutes so one open panel doesn't
|
||||||
// repeat the same call. (Longer caching is the server's job and follows the API's licence terms.)
|
// repeat the same call. (Longer caching is the server's job and follows the API's licence terms.)
|
||||||
const MEMO_MS = 5 * 60 * 1000;
|
const MEMO_MS = 5 * 60 * 1000;
|
||||||
const memo = new Map(); // key -> { at, promise }
|
// Today's verse and devotional are the same all day for everyone, so asking again within the hour is wasted.
|
||||||
|
// (Held in this tab's memory only; never saved.)
|
||||||
|
const DAILY = /\/(?:verse-of-the-day|devotionals\/today)(?:\?|$)/;
|
||||||
|
const DAILY_MS = 60 * 60 * 1000;
|
||||||
|
const FAILED_MS = 60 * 1000; // after "allowance used up" or "unreachable", don't ask again straight away
|
||||||
|
const memo = new Map(); // key -> { at, promise, ttl }
|
||||||
let availability = null;
|
let availability = null;
|
||||||
|
|
||||||
export function iqAvailable() {
|
export function iqAvailable() {
|
||||||
@@ -27,7 +32,7 @@ export const iqUrl = buildPath;
|
|||||||
export function iqGet(path, query, { fresh = false } = {}) {
|
export function iqGet(path, query, { fresh = false } = {}) {
|
||||||
const url = buildPath(path, query);
|
const url = buildPath(path, query);
|
||||||
const hit = memo.get(url);
|
const hit = memo.get(url);
|
||||||
if (!fresh && hit && Date.now() - hit.at < MEMO_MS) return hit.promise;
|
if (!fresh && hit && Date.now() - hit.at < hit.ttl) return hit.promise;
|
||||||
const promise = fetch(url, { credentials: 'include' })
|
const promise = fetch(url, { credentials: 'include' })
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
const data = await res.json().catch(() => null);
|
const data = await res.json().catch(() => null);
|
||||||
@@ -35,8 +40,15 @@ export function iqGet(path, query, { fresh = false } = {}) {
|
|||||||
return { ok: true, data };
|
return { ok: true, data };
|
||||||
})
|
})
|
||||||
.catch(() => ({ ok: false, status: null, error: 'Network error' }));
|
.catch(() => ({ ok: false, status: null, error: 'Network error' }));
|
||||||
memo.set(url, { at: Date.now(), promise });
|
const entry = { at: Date.now(), promise, ttl: DAILY.test(url) ? DAILY_MS : MEMO_MS };
|
||||||
promise.then((r) => { if (!r.ok) memo.delete(url); });
|
memo.set(url, entry);
|
||||||
|
promise.then((r) => {
|
||||||
|
if (r.ok) return;
|
||||||
|
// "Nothing here" (404) is a real answer and is kept like any other; a busy or unreachable service gets a
|
||||||
|
// minute's rest; anything else (a refused request) is forgotten so it can be tried again.
|
||||||
|
if (r.status === 429 || r.status === null || r.status >= 500) entry.ttl = FAILED_MS;
|
||||||
|
else if (r.status !== 404) memo.delete(url);
|
||||||
|
});
|
||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||||
|
import { iqGet, resetIq } from './iqbible.js';
|
||||||
|
|
||||||
|
const answer = (status, body = {}) => ({ ok: status < 400, status, json: async () => body });
|
||||||
|
beforeEach(() => { resetIq(); vi.useFakeTimers(); });
|
||||||
|
afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); });
|
||||||
|
|
||||||
|
test('the same question is asked once, and the answer is reused for a few minutes', async () => {
|
||||||
|
const f = vi.fn(async () => answer(200, { data: 1 })); vi.stubGlobal('fetch', f);
|
||||||
|
await Promise.all([iqGet('geo/JHN/3'), iqGet('geo/JHN/3')]);
|
||||||
|
await iqGet('geo/JHN/3');
|
||||||
|
expect(f).toHaveBeenCalledTimes(1);
|
||||||
|
vi.advanceTimersByTime(6 * 60 * 1000);
|
||||||
|
await iqGet('geo/JHN/3');
|
||||||
|
expect(f).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("today's verse and devotional are reused for the hour", async () => {
|
||||||
|
const f = vi.fn(async () => answer(200, { data: {} })); vi.stubGlobal('fetch', f);
|
||||||
|
await iqGet('bibles/eng_kjv/verse-of-the-day'); await iqGet('devotionals/today');
|
||||||
|
vi.advanceTimersByTime(50 * 60 * 1000);
|
||||||
|
await iqGet('bibles/eng_kjv/verse-of-the-day'); await iqGet('devotionals/today');
|
||||||
|
expect(f).toHaveBeenCalledTimes(2);
|
||||||
|
vi.advanceTimersByTime(11 * 60 * 1000);
|
||||||
|
await iqGet('devotionals/today');
|
||||||
|
expect(f).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('after the allowance runs out it waits a minute, but "nothing here" is kept like any answer', async () => {
|
||||||
|
const busy = vi.fn(async () => answer(429, { error: 'used up' })); vi.stubGlobal('fetch', busy);
|
||||||
|
expect((await iqGet('topics/JHN/3/16')).status).toBe(429);
|
||||||
|
await iqGet('topics/JHN/3/16');
|
||||||
|
expect(busy).toHaveBeenCalledTimes(1);
|
||||||
|
vi.advanceTimersByTime(61_000);
|
||||||
|
await iqGet('topics/JHN/3/16');
|
||||||
|
expect(busy).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
const none = vi.fn(async () => answer(404, { error: 'none' })); vi.stubGlobal('fetch', none);
|
||||||
|
await iqGet('geo/JHN/1'); await iqGet('geo/JHN/1');
|
||||||
|
expect(none).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const refused = vi.fn(async () => answer(400, { error: 'bad' })); vi.stubGlobal('fetch', refused);
|
||||||
|
await iqGet('geo/JHN/2'); await iqGet('geo/JHN/2');
|
||||||
|
expect(refused).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
@@ -29,6 +29,9 @@ export function mergeChunk(mine, theirs) {
|
|||||||
const out = { ...older, ...newer };
|
const out = { ...older, ...newer };
|
||||||
TEXT_FIELDS.forEach((f) => { out[f] = mergeText(newer[f], older[f]); });
|
TEXT_FIELDS.forEach((f) => { out[f] = mergeText(newer[f], older[f]); });
|
||||||
LIST_FIELDS.forEach((f) => { out[f] = union(newer[f], older[f]); });
|
LIST_FIELDS.forEach((f) => { out[f] = union(newer[f], older[f]); });
|
||||||
|
// Pastes follow their text: keep the union per field so merged notes still show what was pasted.
|
||||||
|
out.aiPastes = Object.fromEntries(['observation', 'interpretation', 'application'].map((f) => [f, union(newer.aiPastes?.[f], older.aiPastes?.[f])]));
|
||||||
|
out.recalls = union(newer.recalls, older.recalls);
|
||||||
out.editedAt = Math.max(mine.editedAt ?? 0, theirs.editedAt ?? 0) || undefined;
|
out.editedAt = Math.max(mine.editedAt ?? 0, theirs.editedAt ?? 0) || undefined;
|
||||||
return { chunk: out, changed: !same(out, mine) };
|
return { chunk: out, changed: !same(out, mine) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// "Is this study my own work?" — how much of each chunk's Observe / Interpret /
|
||||||
|
// Apply notes has been written by hand (long pastes don't count as your own). Used to hold back the "→ Claude" prompts
|
||||||
|
// until the thinking has been done by you, not handed to AI.
|
||||||
|
export const OWN_WORK_MINIMUMS = { observation: 20, interpretation: 20, application: 10 };
|
||||||
|
const LABELS = { observation: 'Observe', interpretation: 'Interpret', application: 'Apply' };
|
||||||
|
|
||||||
|
const words = (s) => (s ?? '').trim().split(/\s+/).filter(Boolean).length;
|
||||||
|
|
||||||
|
// A paste this long into a note box is remembered as "not typed by me" (chunk.aiPastes[field] = [text, …]).
|
||||||
|
export const PASTE_MIN_WORDS = 15;
|
||||||
|
export const NOTE_FIELDS = Object.keys(OWN_WORK_MINIMUMS);
|
||||||
|
|
||||||
|
// -> the chunk's new aiPastes after pasting `pasted` into `field`, or null when the paste is too short to count.
|
||||||
|
export function withPaste(chunk, field, pasted) {
|
||||||
|
const text = (pasted ?? '').trim();
|
||||||
|
if (words(text) < PASTE_MIN_WORDS) return null;
|
||||||
|
const all = chunk?.aiPastes ?? {};
|
||||||
|
return { ...all, [field]: [...(all[field] ?? []), text] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Words in `field` that came from a long paste still present in the note (delete or rewrite it and it stops counting).
|
||||||
|
export function pastedWords(chunk, field) {
|
||||||
|
const body = chunk?.[field] ?? '';
|
||||||
|
return (chunk?.aiPastes?.[field] ?? []).filter((t) => body.includes(t)).reduce((n, t) => n + words(t), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ownWords = (chunk, field) => Math.max(0, words(chunk?.[field]) - pastedWords(chunk, field));
|
||||||
|
|
||||||
|
// Share of the three notes' words that are typed or dictated rather than pasted, 0–100 (null when nothing is written).
|
||||||
|
export function ownShare(chunk) {
|
||||||
|
const total = NOTE_FIELDS.reduce((n, f) => n + words(chunk?.[f]), 0);
|
||||||
|
if (!total) return null;
|
||||||
|
return Math.round((NOTE_FIELDS.reduce((n, f) => n + ownWords(chunk, f), 0) / total) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// "That's mine": stop counting the pastes in one field.
|
||||||
|
export const clearPastes = (chunk, field) => ({ ...(chunk?.aiPastes ?? {}), [field]: [] });
|
||||||
|
|
||||||
|
// The note fields of one chunk that are still under their minimum word count.
|
||||||
|
export function thinFields(chunk) {
|
||||||
|
return Object.entries(OWN_WORK_MINIMUMS)
|
||||||
|
.filter(([field, min]) => ownWords(chunk, field) < min)
|
||||||
|
.map(([field]) => ({ field, label: LABELS[field], have: ownWords(chunk, field), need: OWN_WORK_MINIMUMS[field] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chunks that still need your own notes, with what's missing in each.
|
||||||
|
export function chunksNeedingWork(chunks) {
|
||||||
|
return (chunks ?? []).map((chunk) => ({ chunk, missing: thinFields(chunk) })).filter((entry) => entry.missing.length > 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { test, expect } from 'vitest';
|
||||||
|
import { thinFields, chunksNeedingWork } from './ownWork.js';
|
||||||
|
|
||||||
|
const text = (n) => Array.from({ length: n }, (_, i) => `w${i}`).join(' ');
|
||||||
|
|
||||||
|
test('a field counts as done once it reaches its minimum word count', () => {
|
||||||
|
const chunk = { observation: text(20), interpretation: text(19), application: text(10) };
|
||||||
|
expect(thinFields(chunk).map((f) => f.field)).toEqual(['interpretation']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty or missing notes are all flagged', () => {
|
||||||
|
expect(thinFields({}).map((f) => f.label)).toEqual(['Observe', 'Interpret', 'Apply']);
|
||||||
|
expect(thinFields(null)).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only chunks with something missing are returned', () => {
|
||||||
|
const done = { id: 'a', observation: text(25), interpretation: text(25), application: text(12) };
|
||||||
|
const thin = { id: 'b', observation: text(25), interpretation: '', application: text(12) };
|
||||||
|
const result = chunksNeedingWork([done, thin]);
|
||||||
|
expect(result.map((r) => r.chunk.id)).toEqual(['b']);
|
||||||
|
expect(result[0].missing[0]).toMatchObject({ label: 'Interpret', have: 0, need: 20 });
|
||||||
|
});
|
||||||
|
|
||||||
|
import { withPaste, pastedWords, ownWords, ownShare, clearPastes } from './ownWork.js';
|
||||||
|
import { mergeChunk } from './mergeStudies.js';
|
||||||
|
|
||||||
|
test('only long pastes are remembered', () => {
|
||||||
|
expect(withPaste({}, 'observation', text(5))).toBeNull();
|
||||||
|
expect(withPaste({}, 'observation', text(15))).toEqual({ observation: [text(15)] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pasted text stops counting once it is removed from the note', () => {
|
||||||
|
const pasted = text(30);
|
||||||
|
const chunk = { observation: `my own thought ${pasted}`, aiPastes: { observation: [pasted] } };
|
||||||
|
expect(pastedWords(chunk, 'observation')).toBe(30);
|
||||||
|
expect(ownWords(chunk, 'observation')).toBe(3);
|
||||||
|
expect(ownWords({ ...chunk, observation: 'now I wrote this myself' }, 'observation')).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pasted words do not satisfy the own-work minimum', () => {
|
||||||
|
const pasted = text(40);
|
||||||
|
const chunk = { observation: pasted, interpretation: text(25), application: text(12), aiPastes: { observation: [pasted] } };
|
||||||
|
expect(thinFields(chunk).map((f) => f.field)).toEqual(['observation']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the own-words share and "That\'s mine"', () => {
|
||||||
|
const pasted = text(30);
|
||||||
|
const chunk = { observation: `${text(10)} ${pasted}`, aiPastes: { observation: [pasted] } };
|
||||||
|
expect(ownShare(chunk)).toBe(25);
|
||||||
|
expect(ownShare({})).toBeNull();
|
||||||
|
expect(ownShare({ ...chunk, aiPastes: clearPastes(chunk, 'observation') })).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('merging two copies keeps the pastes from both', () => {
|
||||||
|
const a = { id: 'x', observation: 'a', editedAt: 2, aiPastes: { observation: ['p1'] } };
|
||||||
|
const b = { id: 'x', observation: 'b', editedAt: 1, aiPastes: { observation: ['p2'] } };
|
||||||
|
expect(mergeChunk(a, b).chunk.aiPastes.observation).toEqual(['p1', 'p2']);
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// Weekly recall check: once a week, pick a passage you studied a while ago and ask you to write what you
|
||||||
|
// remember of it before seeing your notes. Recalls are kept on the chunk (chunk.recalls = [{ at, text, rating }]).
|
||||||
|
import { formatChunkReference } from './bibleUtils.jsx';
|
||||||
|
import { NOTE_FIELDS, ownWords } from './ownWork.js';
|
||||||
|
|
||||||
|
export const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
export const MIN_OWN_WORDS = 30; // a chunk needs this much of your own writing to be worth testing
|
||||||
|
export const RATINGS = ['solid', 'partly', 'forgot'];
|
||||||
|
|
||||||
|
const lastRecallAt = (chunk) => Math.max(0, ...(chunk.recalls ?? []).map((r) => r.at ?? 0));
|
||||||
|
const ownTotal = (chunk) => NOTE_FIELDS.reduce((n, f) => n + ownWords(chunk, f), 0);
|
||||||
|
|
||||||
|
// -> the passage to recall this week, or null when you already did one in the last 7 days or nothing qualifies.
|
||||||
|
// `projects` are full study projects. Studied at least a week ago, never-recalled first, then the longest ago.
|
||||||
|
export function pickRecall(projects, now = Date.now()) {
|
||||||
|
const all = (projects ?? []).flatMap((project) => (project?.chapters ?? []).flatMap((chapter, chapterIndex) => (chapter.chunks ?? []).map((chunk) => ({ project, chapterIndex, chunk }))));
|
||||||
|
if (all.some(({ chunk }) => lastRecallAt(chunk) > 0 && now - lastRecallAt(chunk) < WEEK_MS)) return null;
|
||||||
|
const [best] = all
|
||||||
|
.filter(({ chunk }) => ownTotal(chunk) >= MIN_OWN_WORDS && now - (chunk.editedAt ?? 0) >= WEEK_MS)
|
||||||
|
.sort((a, b) => lastRecallAt(a.chunk) - lastRecallAt(b.chunk) || (a.chunk.editedAt ?? 0) - (b.chunk.editedAt ?? 0));
|
||||||
|
if (!best) return null;
|
||||||
|
return {
|
||||||
|
projectId: best.project.id, projectTitle: best.project.title, chunk: best.chunk,
|
||||||
|
reference: formatChunkReference(best.project, best.chapterIndex, best.chunk, '–'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every recall ever saved, newest first: [{ at, rating, text, reference, projectId, projectTitle, chunkId }].
|
||||||
|
export function recallHistory(projects) {
|
||||||
|
return (projects ?? []).flatMap((project) => (project?.chapters ?? []).flatMap((chapter, chapterIndex) => (chapter.chunks ?? []).flatMap((chunk) => (
|
||||||
|
(chunk.recalls ?? []).map((r) => ({
|
||||||
|
at: r.at ?? 0, rating: r.rating, text: r.text ?? '', chunkId: chunk.id,
|
||||||
|
projectId: project.id, projectTitle: project.title, reference: formatChunkReference(project, chapterIndex, chunk, '–'),
|
||||||
|
}))
|
||||||
|
)))).sort((a, b) => b.at - a.at);
|
||||||
|
}
|
||||||
|
|
||||||
|
// How the recalls went: counts per rating, plus the last `weeks` weeks (oldest first) as { start, rating|null }
|
||||||
|
// where rating is the weakest one that week (so a week isn't flattered by one good recall).
|
||||||
|
export function recallSummary(history, now = Date.now(), weeks = 8) {
|
||||||
|
const counts = { solid: 0, partly: 0, forgot: 0 };
|
||||||
|
history.forEach((h) => { if (h.rating in counts) counts[h.rating] += 1; });
|
||||||
|
const rank = { forgot: 0, partly: 1, solid: 2 };
|
||||||
|
const recent = Array.from({ length: weeks }, (_, i) => {
|
||||||
|
const start = now - (weeks - i) * WEEK_MS;
|
||||||
|
const inWeek = history.filter((h) => h.at > start && h.at <= start + WEEK_MS && h.rating in rank);
|
||||||
|
const weakest = inWeek.sort((a, b) => rank[a.rating] - rank[b.rating])[0];
|
||||||
|
return { start, rating: weakest?.rating ?? null };
|
||||||
|
});
|
||||||
|
return { counts, total: history.length, recent };
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { test, expect } from 'vitest';
|
||||||
|
import { pickRecall, WEEK_MS } from './recall.js';
|
||||||
|
|
||||||
|
const NOW = 100 * WEEK_MS;
|
||||||
|
const text = (n) => Array.from({ length: n }, (_, i) => `w${i}`).join(' ');
|
||||||
|
const chunk = (id, extra = {}) => ({ id, startVerse: 1, endVerse: 5, observation: text(40), editedAt: NOW - 2 * WEEK_MS, ...extra });
|
||||||
|
const study = (chunks, id = 'p1') => ({ id, title: 'Romans', translation: 'BSB', chapters: [{ book: 'Romans', bookAbbrev: 'Rom', chapter: 8, chunks }] });
|
||||||
|
|
||||||
|
test('picks a studied chunk that has enough of your own writing', () => {
|
||||||
|
const pick = pickRecall([study([chunk('a')])], NOW);
|
||||||
|
expect(pick).toMatchObject({ projectId: 'p1', projectTitle: 'Romans' });
|
||||||
|
expect(pick.chunk.id).toBe('a');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips thin notes, pasted notes and chunks edited this week', () => {
|
||||||
|
const pasted = text(40);
|
||||||
|
const projects = [study([
|
||||||
|
chunk('thin', { observation: text(10) }),
|
||||||
|
chunk('pasted', { observation: pasted, aiPastes: { observation: [pasted] } }),
|
||||||
|
chunk('fresh', { editedAt: NOW - 1000 }),
|
||||||
|
])];
|
||||||
|
expect(pickRecall(projects, NOW)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('never-recalled chunks come before ones recalled long ago', () => {
|
||||||
|
const old = chunk('old', { recalls: [{ at: NOW - 3 * WEEK_MS, text: 'x', rating: 'solid' }] });
|
||||||
|
expect(pickRecall([study([old, chunk('new')])], NOW).chunk.id).toBe('new');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('one recall a week: nothing is offered after you have done one', () => {
|
||||||
|
const done = chunk('done', { recalls: [{ at: NOW - 1000, text: 'x', rating: 'partly' }] });
|
||||||
|
expect(pickRecall([study([done, chunk('other')])], NOW)).toBeNull();
|
||||||
|
expect(pickRecall([study([done, chunk('other')])], NOW + WEEK_MS)).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
import { recallHistory, recallSummary } from './recall.js';
|
||||||
|
|
||||||
|
test('history lists every recall newest first, with where it came from', () => {
|
||||||
|
const a = chunk('a', { recalls: [{ at: NOW - 3 * WEEK_MS, text: 'old', rating: 'forgot' }, { at: NOW - 1000, text: 'new', rating: 'solid' }] });
|
||||||
|
const h = recallHistory([study([a])]);
|
||||||
|
expect(h.map((x) => x.text)).toEqual(['new', 'old']);
|
||||||
|
expect(h[0]).toMatchObject({ projectTitle: 'Romans', chunkId: 'a' });
|
||||||
|
expect(h[0].reference).toContain('Romans');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the summary counts ratings and marks each of the last eight weeks by its weakest recall', () => {
|
||||||
|
const history = [
|
||||||
|
{ at: NOW - 1000, rating: 'solid' }, { at: NOW - 2000, rating: 'partly' },
|
||||||
|
{ at: NOW - 2 * WEEK_MS + 1000, rating: 'forgot' },
|
||||||
|
];
|
||||||
|
const { counts, total, recent } = recallSummary(history, NOW);
|
||||||
|
expect(counts).toEqual({ solid: 1, partly: 1, forgot: 1 });
|
||||||
|
expect(total).toBe(3);
|
||||||
|
expect(recent).toHaveLength(8);
|
||||||
|
expect(recent[7].rating).toBe('partly'); // this week: the weaker of solid and partly
|
||||||
|
expect(recent[6].rating).toBe('forgot'); // last week
|
||||||
|
expect(recent[0].rating).toBeNull();
|
||||||
|
});
|
||||||
Reference in new issue
Block a user