Compare commits
38
Commits
d29a66f984
...
main
No files matched your search
@@ -13,3 +13,11 @@ coverage/
|
|||||||
.env
|
.env
|
||||||
.session-secret
|
.session-secret
|
||||||
server/data/backups/
|
server/data/backups/
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
e2e/.auth.json
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
|
||||||
|
# a local demo video, not part of the app
|
||||||
|
demo/
|
||||||
@@ -151,10 +151,17 @@ Private / personal use.
|
|||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run test:run # all tests
|
npm run test:run # all unit and component tests
|
||||||
|
npm run test:e2e # real-browser tests (Playwright, in your installed Chrome)
|
||||||
npm run lint # ESLint (errors fail; warnings are advice)
|
npm run lint # ESLint (errors fail; warnings are advice)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The browser tests (`e2e/`) build the app, run it on port 3101 with an empty throwaway database (your real studies are never
|
||||||
|
touched), make an account, and click through the main flows: writing notes and the paste check, the daily five minutes and weekly
|
||||||
|
recall, comparing translations (Bible text is faked, so no internet is needed), and presenting on two screens. They use the
|
||||||
|
Chrome already on the computer, so there is no browser to download; install Chrome, or change `channel` in `playwright.config.js`
|
||||||
|
and run `npx playwright install` to use Playwright's own browser.
|
||||||
|
|
||||||
Who can sign up: the administrator (the `ADMIN_EMAIL` account) chooses Open, Invite only or Closed under Admin → Sign-ups, and makes single-use invite links there. The very first account can always be created.
|
Who can sign up: the administrator (the `ADMIN_EMAIL` account) chooses Open, Invite only or Closed under Admin → Sign-ups, and makes single-use invite links there. The very first account can always be created.
|
||||||
|
|
||||||
Verse of the day push: with `IQBIBLE_API_KEY` set, each person can turn on a daily verse notification in Settings → Reading reminders (it goes to the same ntfy topic).
|
Verse of the day push: with `IQBIBLE_API_KEY` set, each person can turn on a daily verse notification in Settings → Reading reminders (it goes to the same ntfy topic).
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ Ideas for the app that haven't been built yet, roughly in the order I'd do them.
|
|||||||
"sign out other devices"; optionally a login alert through ntfy.
|
"sign out other devices"; optionally a login alert through ntfy.
|
||||||
- [x] **Server health page for the admin.** Last backup, disk use, failed notifications, recent sign-in failures, and a
|
- [x] **Server health page for the admin.** Last backup, disk use, failed notifications, recent sign-in failures, and a
|
||||||
"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 `npm run test:run`, `npm run lint` and `npm run test:e2e` on the Gitea server for every push.
|
||||||
creating a study and syncing; so far everything is tested in simulation.
|
The real-browser tests now exist (`e2e/`, run by hand); what's left is running them automatically, and adding sign-in
|
||||||
- [ ] **Split `server/index.js`** (now about 1,100 lines) into one file per area (auth, projects, sharing, reader data, admin).
|
and cross-device sync to them.
|
||||||
|
- [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.
|
- [x] **Comments between co-teachers.** Comments on a chunk (under "More"), merged between copies and ticked off when dealt with.
|
||||||
|
Still to do: address a comment to one person, and notify them.
|
||||||
- [ ] **Live updates and "who's here".** A co-teacher's changes appear within seconds, with a marker like "Sam is on chunk 3"
|
- [ ] **Live updates and "who's here".** A co-teacher's changes appear within seconds, with a marker like "Sam is on chunk 3"
|
||||||
(server-sent events or short polling). Today changes arrive when the tab regains focus or every couple of minutes.
|
(server-sent events or short polling). Today changes arrive when the tab regains focus or every couple of minutes.
|
||||||
- [ ] **Ask Claude inside the app.** Draft discussion questions, tighten an outline point, summarise notes, using the owner's own
|
- [ ] **Ask Claude inside the app.** Draft discussion questions, tighten an outline point, summarise notes, using the owner's own
|
||||||
@@ -22,7 +24,7 @@ Ideas for the app that haven't been built yet, roughly in the order I'd do them.
|
|||||||
|
|
||||||
## Reading
|
## Reading
|
||||||
- [ ] **Read-aloud with highlighting.** The device's voice reads the chapter and the verse being read is highlighted; useful for
|
- [ ] **Read-aloud with highlighting.** The device's voice reads the chapter and the verse being read is highlighted; useful for
|
||||||
the BSB and NET, which have no audio.
|
the NET and other translations that have no matching audio (the BSB and ESV already have narration).
|
||||||
- [x] **Cross-reference map.** A picture of the links between chapters, from IQ Bible's reference graph (needs the IQ Bible key).
|
- [x] **Cross-reference map.** A picture of the links between chapters, from IQ Bible's reference graph (needs the IQ Bible key).
|
||||||
|
|
||||||
## Decided against, for now
|
## Decided against, for now
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// Makes one account in the throwaway database and keeps its sign-in for the other tests.
|
||||||
|
import { test as setup, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
setup('create an account and sign in', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('button', { name: /need an account\? sign up/i }).click();
|
||||||
|
await page.locator('input[type="email"]').fill('e2e@example.test');
|
||||||
|
await page.locator('input[type="password"]').fill('E2e-Test-Password-7391');
|
||||||
|
await page.getByRole('button', { name: 'Create account' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'My Studies' })).toBeVisible();
|
||||||
|
await page.context().storageState({ path: 'e2e/.auth.json' });
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { resumeButton, seedStudy } from './helpers.js';
|
||||||
|
|
||||||
|
// Fake the free Bible text so the test needs no internet: every translation answers with its own wording.
|
||||||
|
const WORDING = {
|
||||||
|
BSB: { 16: 'For God so loved the world that He gave His one and only Son', 17: 'For God did not send His Son into the world to condemn the world', 18: 'Whoever believes in Him is not condemned' },
|
||||||
|
NET: { 16: 'For this is the way God loved the world: He gave his one and only Son', 17: 'For God did not send his Son into the world to condemn the world', 18: 'The one who believes in him is not condemned' },
|
||||||
|
};
|
||||||
|
|
||||||
|
test('compare translations with differences highlighted, a verse note, and the notes boxes', async ({ page }) => {
|
||||||
|
await page.route('https://bible.helloao.org/api/**', async (route) => {
|
||||||
|
const id = route.request().url().match(/api\/([^/]+)\//)[1].replace('eng_', '').toUpperCase();
|
||||||
|
const words = WORDING[id] ?? WORDING.BSB;
|
||||||
|
await route.fulfill({ json: { verses: Object.entries(words).map(([number, text]) => ({ number: Number(number), text })) } });
|
||||||
|
});
|
||||||
|
const { title } = await seedStudy(page);
|
||||||
|
await resumeButton(page, title).click();
|
||||||
|
await page.getByRole('button', { name: /compare…/i }).click();
|
||||||
|
await page.getByRole('tab', { name: 'Translations' }).click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog', { name: /compare/i });
|
||||||
|
await expect(dialog.getByText('Berean Standard Bible')).toBeVisible();
|
||||||
|
await expect(dialog.getByText('NET Bible', { exact: true })).toBeVisible();
|
||||||
|
await expect(dialog.locator('mark').first()).toBeVisible(); // words that differ are marked
|
||||||
|
await dialog.getByLabel(/highlight the words that differ/i).uncheck();
|
||||||
|
await expect(dialog.locator('mark')).toHaveCount(0);
|
||||||
|
|
||||||
|
// a note on one verse, and the Observe / Interpret / Apply boxes edit the same notes as the Study page
|
||||||
|
await dialog.getByRole('textbox', { name: 'Note on verse 16' }).fill('NET says "the way" where BSB says "so"');
|
||||||
|
await expect(dialog.getByRole('textbox', { name: 'Interpret' })).toHaveValue(/not a slogan/);
|
||||||
|
await dialog.getByRole('textbox', { name: 'Apply' }).fill('Decide one thing to trust him with.');
|
||||||
|
await dialog.getByRole('button', { name: 'Close' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Your notes' }).first().click(); // keep both halves tidy
|
||||||
|
await page.keyboard.press('3');
|
||||||
|
await expect(page.getByRole('textbox', { name: 'Apply' })).toHaveValue('Decide one thing to trust him with.');
|
||||||
|
await expect(page.getByRole('textbox', { name: 'Verse 16 note' })).toHaveValue(/the way/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// Shared test helpers: a ready-made study so tests don't depend on downloading Bible text.
|
||||||
|
const DAY = 86400000;
|
||||||
|
const verses = [
|
||||||
|
{ number: 16, text: 'For God so loved the world, that he gave his only begotten Son, that whosoever believeth in him should not perish, but have everlasting life.' },
|
||||||
|
{ number: 17, text: 'For God sent not his Son into the world to condemn the world; but that the world through him might be saved.' },
|
||||||
|
{ number: 18, text: 'He that believeth on him is not condemned: but he that believeth not is condemned already.' },
|
||||||
|
{ number: 19, text: 'And this is the condemnation, that light is come into the world, and men loved darkness rather than light, because their deeds were evil.' },
|
||||||
|
];
|
||||||
|
export const NOTE_TEXT = {
|
||||||
|
observation: 'Jesus is speaking to Nicodemus at night. The verse starts with "for", so it explains the previous verses about the serpent lifted up. God is the one who acts: he loved, he gave.',
|
||||||
|
interpretation: 'In its setting this is not a slogan but the reason the cross happens. The world is the object of love, not just the good in it. Giving the Son points back to Moses lifting the serpent.',
|
||||||
|
application: 'I do not have to earn being loved before I trust him. This week I want to stop treating belief as a feeling and decide one concrete thing to trust him with.',
|
||||||
|
};
|
||||||
|
export const SPEAKER_NOTE = 'PRIVATE: open with the story about Nicodemus at night, then pause after perish.';
|
||||||
|
|
||||||
|
// Writes a two-chunk John 3 study (edited two weeks ago, so a weekly recall is due) into this browser's storage
|
||||||
|
// for the signed-in account, then reloads.
|
||||||
|
export async function seedStudy(page, { id = `e2e-${Date.now()}-${Math.floor(Math.random() * 1e6)}`, withOutline = false } = {}) {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForFunction(() => Object.keys(localStorage).some((k) => /^bible-study-index:.+:known$/.test(k)));
|
||||||
|
await page.evaluate(({ id, verses, notes, speakerNote, withOutline, DAY }) => {
|
||||||
|
const uid = Object.keys(localStorage).find((k) => /^bible-study-index:.+:known$/.test(k)).split(':')[1];
|
||||||
|
const now = Date.now();
|
||||||
|
const chunk = (cid, s, e, extra) => ({ id: cid, startVerse: s, endVerse: e, spilloverEndVerse: null, observation: '', interpretation: '', application: '', crossReferences: [], greekWords: [], generalNotes: '', episodeNumber: '', episodeTitle: '', finalScript: '', tags: [], editedAt: now - 14 * DAY, ...extra });
|
||||||
|
const node = (nid, text, extra = {}) => ({ id: nid, text, ref: '', chunkId: null, note: '', kind: '', minutes: null, scripture: null, attachments: [], children: [], ...extra });
|
||||||
|
const a = chunk(`${id}-a`, 16, 18, { ...notes });
|
||||||
|
const b = chunk(`${id}-b`, 19, 19, {});
|
||||||
|
// a title of its own: studies from earlier tests sync back through the account, so titles must not repeat
|
||||||
|
const title = `John 3 (${id.slice(-6)})`;
|
||||||
|
const outline = withOutline ? [node('n1', 'God so loved', { minutes: 5, note: speakerNote, ref: 'John 3:16', scripture: { text: verses[0].text, ref: 'John 3:16' } }), node('n2', 'Light and darkness', { minutes: 5 })] : [];
|
||||||
|
const project = { id, title, translation: 'BSB', lastEdited: now - 2 * DAY, selectedChunkId: a.id, outline, chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses, chunks: [a, b] }] };
|
||||||
|
localStorage.setItem(`bible-study-project:${uid}:${id}`, JSON.stringify(project));
|
||||||
|
const index = JSON.parse(localStorage.getItem(`bible-study-index:${uid}`) || '[]').filter((e) => e.id !== id);
|
||||||
|
index.push({ id, title: project.title, lastEdited: project.lastEdited, chapterSummary: 'John 3', series: '', tags: [] });
|
||||||
|
localStorage.setItem(`bible-study-index:${uid}`, JSON.stringify(index));
|
||||||
|
}, { id, verses, notes: NOTE_TEXT, speakerNote: SPEAKER_NOTE, withOutline, DAY });
|
||||||
|
await page.reload();
|
||||||
|
return { id, title: `John 3 (${id.slice(-6)})` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Resume button on one study's card on Home
|
||||||
|
export const resumeButton = (page, title) => page.getByRole('heading', { name: title }).locator('xpath=ancestor::div[contains(@class,"rounded-2xl")][1]').getByRole('button', { name: 'Resume' });
|
||||||
|
|
||||||
|
// Fakes a long paste into a note box (Playwright can't use the real clipboard in every setup)
|
||||||
|
export async function pasteInto(locator, text) {
|
||||||
|
await locator.evaluate((el, value) => {
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
dt.setData('text', value);
|
||||||
|
el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
|
||||||
|
}, text);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { pasteInto, seedStudy } from './helpers.js';
|
||||||
|
|
||||||
|
test('the daily five minutes blocks pasting and saves to the note; the weekly recall rates and records', async ({ page }) => {
|
||||||
|
await seedStudy(page);
|
||||||
|
|
||||||
|
// daily five minutes
|
||||||
|
await expect(page.getByText(/your five minutes:/i)).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Start' }).first().click();
|
||||||
|
const box = page.getByRole('textbox', { name: /your five minutes/i });
|
||||||
|
await expect(page.getByRole('timer')).toContainText('4:');
|
||||||
|
await pasteInto(box, 'text from somewhere else');
|
||||||
|
await expect(page.getByText(/pasting is off here/i)).toBeVisible();
|
||||||
|
await expect(box).toHaveValue('');
|
||||||
|
await box.fill('Three things I notice in the passage.');
|
||||||
|
await page.getByRole('button', { name: /save to my notes/i }).click();
|
||||||
|
await expect(page.getByText(/saved to your notes/i)).toBeVisible();
|
||||||
|
|
||||||
|
// weekly recall: write from memory, compare with the notes, rate it
|
||||||
|
await page.getByRole('button', { name: 'Start' }).click();
|
||||||
|
await page.getByRole('textbox', { name: 'What you remember' }).fill('God loved the world and gave his Son so that believers would not perish.');
|
||||||
|
await page.getByRole('button', { name: 'Show my notes' }).click();
|
||||||
|
await expect(page.getByText('Your notes', { exact: true })).toBeVisible();
|
||||||
|
await page.getByRole('textbox', { name: 'What slipped' }).fill('who Jesus was talking to');
|
||||||
|
await page.getByRole('button', { name: 'Partly' }).click();
|
||||||
|
await expect(page.getByText(/recall saved/i)).toBeVisible();
|
||||||
|
|
||||||
|
// it shows in the history and in Insights
|
||||||
|
await page.getByRole('button', { name: /recall history/i }).click();
|
||||||
|
await expect(page.getByText('Partly', { exact: true }).first()).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Insights' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Insights' })).toBeVisible();
|
||||||
|
await expect(page.getByText('who Jesus was talking to')).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { SPEAKER_NOTE, resumeButton, seedStudy } from './helpers.js';
|
||||||
|
|
||||||
|
test('presenting on two screens: the audience window never shows the speaker notes', async ({ page, context }) => {
|
||||||
|
const { title } = await seedStudy(page, { withOutline: true });
|
||||||
|
await resumeButton(page, title).click();
|
||||||
|
await page.getByRole('navigation', { name: 'Study stages' }).getByRole('button', { name: 'Outline' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Present' }).click();
|
||||||
|
const presenter = page.getByRole('dialog', { name: /presenting/i });
|
||||||
|
|
||||||
|
// one screen: notes stay hidden until turned on
|
||||||
|
await expect(presenter.getByRole('heading', { name: /God so loved/ })).toBeVisible();
|
||||||
|
await expect(presenter.getByText(SPEAKER_NOTE)).toHaveCount(0);
|
||||||
|
|
||||||
|
// a second window (the projector) connects and follows along
|
||||||
|
const audience = await context.newPage();
|
||||||
|
await audience.goto('/?audience=1');
|
||||||
|
await expect(audience.getByRole('heading', { name: /God so loved/ })).toBeVisible();
|
||||||
|
await expect(presenter.getByText('Only you see this')).toBeVisible();
|
||||||
|
await expect(presenter.getByText(SPEAKER_NOTE)).toBeVisible(); // on the presenter's screen only
|
||||||
|
await expect(audience.getByText(SPEAKER_NOTE)).toHaveCount(0);
|
||||||
|
expect(await audience.locator('body').innerText()).not.toMatch(/PRIVATE|On pace|Start/);
|
||||||
|
|
||||||
|
await page.keyboard.press('ArrowRight');
|
||||||
|
await expect(audience.getByRole('heading', { name: /Light and darkness/ })).toBeVisible();
|
||||||
|
|
||||||
|
// B blacks the audience screen out, and again brings it back
|
||||||
|
await page.keyboard.press('b');
|
||||||
|
await expect(audience.getByRole('heading', { name: /Light and darkness/ })).toHaveCount(0);
|
||||||
|
await page.keyboard.press('b');
|
||||||
|
await expect(audience.getByRole('heading', { name: /Light and darkness/ })).toBeVisible();
|
||||||
|
|
||||||
|
// closing the presentation tells the audience window
|
||||||
|
await presenter.getByRole('button', { name: 'Close' }).click();
|
||||||
|
await expect(audience.getByText(/presentation has ended/i)).toBeVisible();
|
||||||
|
});
|
||||||
Executable
+9
@@ -0,0 +1,9 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Builds the app and runs the server on port 3101 with a fresh, empty data folder (so tests never touch real studies).
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
export DATA_DIR="$(mktemp -d)"
|
||||||
|
export PORT=3101
|
||||||
|
export NODE_ENV=production
|
||||||
|
npx vite build --logLevel error
|
||||||
|
exec node server/index.js
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { NOTE_TEXT, pasteInto, resumeButton, seedStudy } from './helpers.js';
|
||||||
|
|
||||||
|
test('open a study, write notes, answer the paste check, and use the keyboard', async ({ page }) => {
|
||||||
|
const { title } = await seedStudy(page);
|
||||||
|
await expect(page.getByRole('heading', { name: title })).toBeVisible();
|
||||||
|
await expect(page.getByText(/needs a connection for john/i).first()).toBeVisible(); // the offline badge
|
||||||
|
await resumeButton(page, title).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'John 3:16–18' })).toBeVisible();
|
||||||
|
|
||||||
|
// keyboard: 2 jumps into Interpret, Escape leaves it
|
||||||
|
await page.keyboard.press('2');
|
||||||
|
await expect(page.getByRole('textbox', { name: 'Interpret' })).toBeFocused();
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(page.getByRole('textbox', { name: 'Interpret' })).not.toBeFocused();
|
||||||
|
|
||||||
|
// a long paste asks whether it is from AI; "No, I wrote it" counts the words as yours
|
||||||
|
await page.keyboard.press('1');
|
||||||
|
const observe = page.getByRole('textbox', { name: 'Observe' });
|
||||||
|
const pasted = Array.from({ length: 24 }, (_, i) => `pasted${i}`).join(' ');
|
||||||
|
await pasteInto(observe, pasted);
|
||||||
|
await observe.fill(`${NOTE_TEXT.observation} ${pasted}`);
|
||||||
|
await expect(page.getByText(/is this from ai\?/i)).toBeVisible();
|
||||||
|
await expect(page.getByText(/24 words here were pasted/i)).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'No, I wrote it' }).click();
|
||||||
|
await expect(page.getByText(/is this from ai\?/i)).toHaveCount(0);
|
||||||
|
await expect(page.getByText(/were pasted/i)).toHaveCount(0);
|
||||||
|
|
||||||
|
// either half of the right column can be folded
|
||||||
|
const resources = page.getByRole('button', { name: /commentary & resources/i });
|
||||||
|
await resources.click();
|
||||||
|
await expect(resources).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
await page.getByRole('button', { name: 'Your notes' }).click(); // folding the last open half opens the other
|
||||||
|
await expect(resources).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
|
||||||
|
// the reading list belongs to the study
|
||||||
|
await page.getByRole('button', { name: 'Your notes' }).click(); // make room: fold notes, resources stay open
|
||||||
|
await page.getByRole('tab', { name: /reading list/i }).click();
|
||||||
|
await page.getByRole('textbox', { name: /add to the reading list/i }).fill('Carson on John');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.getByRole('checkbox', { name: /read: carson on john/i }).check();
|
||||||
|
await expect(page.getByRole('checkbox', { name: /read: carson on john/i })).toBeChecked();
|
||||||
|
});
|
||||||
+7
-1
@@ -22,10 +22,16 @@ export default [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: ['server/**/*.js', 'scripts/**/*.mjs', 'vite.config.js', 'eslint.config.js', 'postcss.config.js', 'tailwind.config.js'],
|
files: ['server/**/*.js', 'scripts/**/*.mjs', 'vite.config.js', 'playwright.config.js', 'eslint.config.js', 'postcss.config.js', 'tailwind.config.js'],
|
||||||
languageOptions: { ecmaVersion: 'latest', sourceType: 'module', globals: { ...globals.node } },
|
languageOptions: { ecmaVersion: 'latest', sourceType: 'module', globals: { ...globals.node } },
|
||||||
rules: { 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', caughtErrors: 'none' }], 'no-empty': ['error', { allowEmptyCatch: true }] },
|
rules: { 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', caughtErrors: 'none' }], 'no-empty': ['error', { allowEmptyCatch: true }] },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Playwright tests run in Node but hand functions to the browser page, which use browser globals
|
||||||
|
files: ['e2e/**/*.js'],
|
||||||
|
languageOptions: { ecmaVersion: 'latest', sourceType: 'module', globals: { ...globals.node, ...globals.browser } },
|
||||||
|
rules: { 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', caughtErrors: 'none' }] },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
files: ['**/*.test.{js,jsx}', 'src/setupTests.js', 'src/test/**'],
|
files: ['**/*.test.{js,jsx}', 'src/setupTests.js', 'src/test/**'],
|
||||||
languageOptions: { globals: { ...globals.browser, ...globals.node, vi: 'readonly' } },
|
languageOptions: { globals: { ...globals.browser, ...globals.node, vi: 'readonly' } },
|
||||||
|
|||||||
Generated
+46
@@ -27,6 +27,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@playwright/test": "^1.64.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
@@ -1635,6 +1636,22 @@
|
|||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.64.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
|
||||||
|
"integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.64.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz",
|
||||||
@@ -6190,6 +6207,35 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.64.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
|
||||||
|
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.64.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.64.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
|
||||||
|
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pngjs": {
|
"node_modules/pngjs": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||||
|
|||||||
+3
-1
@@ -15,7 +15,8 @@
|
|||||||
"coverage": "vitest run --coverage",
|
"coverage": "vitest run --coverage",
|
||||||
"backup": "bash scripts/backup-data.sh",
|
"backup": "bash scripts/backup-data.sh",
|
||||||
"restore": "bash scripts/restore-data.sh",
|
"restore": "bash scripts/restore-data.sh",
|
||||||
"lint": "eslint ."
|
"lint": "eslint .",
|
||||||
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@simplewebauthn/browser": "^13.3.0",
|
"@simplewebauthn/browser": "^13.3.0",
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@playwright/test": "^1.64.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Real-browser tests: a production build of the app on its own port with a throwaway database, driven in Chrome.
|
||||||
|
// Run with `npm run test:e2e`. It uses the Chrome already on this computer (no browser download).
|
||||||
|
import { defineConfig } from '@playwright/test';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
timeout: 60_000,
|
||||||
|
expect: { timeout: 10_000 },
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
reporter: [['list']],
|
||||||
|
use: { baseURL: 'http://localhost:3101', channel: 'chrome', trace: 'retain-on-failure' },
|
||||||
|
webServer: { command: 'bash e2e/start-server.sh', url: 'http://localhost:3101/', timeout: 120_000, reuseExistingServer: false },
|
||||||
|
projects: [
|
||||||
|
{ name: 'setup', testMatch: /auth\.setup\.js/ },
|
||||||
|
{ name: 'chrome', testMatch: /.*\.spec\.js/, dependencies: ['setup'], use: { storageState: 'e2e/.auth.json' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 90 KiB |
+14
-12
@@ -140,7 +140,7 @@ export function initDb() {
|
|||||||
db.exec('ALTER TABLE projects ADD COLUMN outline_share_token TEXT');
|
db.exec('ALTER TABLE projects ADD COLUMN outline_share_token TEXT');
|
||||||
}
|
}
|
||||||
// Share links can expire and be protected by a passcode.
|
// Share links can expire and be protected by a passcode.
|
||||||
for (const col of ['share_expires INTEGER', 'share_pass TEXT', 'outline_share_expires INTEGER', 'outline_share_pass TEXT']) {
|
for (const col of ['share_expires INTEGER', 'share_pass TEXT', 'share_reveal INTEGER', 'outline_share_expires INTEGER', 'outline_share_pass TEXT']) {
|
||||||
if (!projectCols.some((c) => c.name === col.split(' ')[0])) db.exec(`ALTER TABLE projects ADD COLUMN ${col}`);
|
if (!projectCols.some((c) => c.name === col.split(' ')[0])) db.exec(`ALTER TABLE projects ADD COLUMN ${col}`);
|
||||||
}
|
}
|
||||||
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_outline_share_token ON projects(outline_share_token) WHERE outline_share_token IS NOT NULL');
|
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_outline_share_token ON projects(outline_share_token) WHERE outline_share_token IS NOT NULL');
|
||||||
@@ -780,7 +780,7 @@ export function setShareToken(id, userId, token) {
|
|||||||
|
|
||||||
/** Revokes a project's share link, scoped to userId. */
|
/** Revokes a project's share link, scoped to userId. */
|
||||||
export function clearShareToken(id, userId) {
|
export function clearShareToken(id, userId) {
|
||||||
db.prepare('UPDATE projects SET share_token = NULL, share_expires = NULL, share_pass = NULL WHERE id = ? AND user_id = ?').run(id, userId);
|
db.prepare('UPDATE projects SET share_token = NULL, share_expires = NULL, share_pass = NULL, share_reveal = NULL WHERE id = ? AND user_id = ?').run(id, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- read-only outline links: a separate token, so sharing an outline never shares the whole study ----
|
// ---- read-only outline links: a separate token, so sharing an outline never shares the whole study ----
|
||||||
@@ -800,14 +800,14 @@ export function clearOutlineShareToken(id, userId) {
|
|||||||
|
|
||||||
// ---- expiry and passcode for either kind of read-only link (outline = the outline-only link) ----
|
// ---- expiry and passcode for either kind of read-only link (outline = the outline-only link) ----
|
||||||
const shareCols = (outline) => (outline
|
const shareCols = (outline) => (outline
|
||||||
? { token: 'outline_share_token', expires: 'outline_share_expires', pass: 'outline_share_pass' }
|
? { token: 'outline_share_token', expires: 'outline_share_expires', pass: 'outline_share_pass', reveal: 'NULL' }
|
||||||
: { token: 'share_token', expires: 'share_expires', pass: 'share_pass' });
|
: { token: 'share_token', expires: 'share_expires', pass: 'share_pass', reveal: 'share_reveal' });
|
||||||
|
|
||||||
/** { token, expiresAt, hasPasscode } for the owner's link settings. */
|
/** { token, expiresAt, hasPasscode, revealAt } for the owner's link settings (revealAt: notes stay hidden until then). */
|
||||||
export function getShareSettings(id, userId, outline = false) {
|
export function getShareSettings(id, userId, outline = false) {
|
||||||
const c = shareCols(outline);
|
const c = shareCols(outline);
|
||||||
const row = db.prepare(`SELECT ${c.token} AS token, ${c.expires} AS expiresAt, ${c.pass} AS pass FROM projects WHERE id = ? AND user_id = ?`).get(id, userId);
|
const row = db.prepare(`SELECT ${c.token} AS token, ${c.expires} AS expiresAt, ${c.pass} AS pass, ${c.reveal} AS revealAt FROM projects WHERE id = ? AND user_id = ?`).get(id, userId);
|
||||||
return row ? { token: row.token ?? null, expiresAt: row.expiresAt ?? null, hasPasscode: !!row.pass } : null;
|
return row ? { token: row.token ?? null, expiresAt: row.expiresAt ?? null, hasPasscode: !!row.pass, revealAt: row.revealAt ?? null } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The stored passcode hash of a link (kept when only the expiry is changed). */
|
/** The stored passcode hash of a link (kept when only the expiry is changed). */
|
||||||
@@ -816,18 +816,20 @@ export function getSharePassHash(id, userId, outline = false) {
|
|||||||
return db.prepare(`SELECT ${c.pass} AS pass FROM projects WHERE id = ? AND user_id = ?`).get(id, userId)?.pass ?? null;
|
return db.prepare(`SELECT ${c.pass} AS pass FROM projects WHERE id = ? AND user_id = ?`).get(id, userId)?.pass ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sets expiry (ms timestamp or null) and passcode hash (or null) on an existing link. */
|
/** Sets expiry (ms timestamp or null), passcode hash (or null) and, for a study link, when its notes appear (or null). */
|
||||||
export function setShareSettings(id, userId, { expiresAt, passHash }, outline = false) {
|
export function setShareSettings(id, userId, { expiresAt, passHash, revealAt }, outline = false) {
|
||||||
const c = shareCols(outline);
|
const c = shareCols(outline);
|
||||||
return db.prepare(`UPDATE projects SET ${c.expires} = ?, ${c.pass} = ? WHERE id = ? AND user_id = ?`).run(expiresAt ?? null, passHash ?? null, id, userId).changes > 0;
|
const changed = db.prepare(`UPDATE projects SET ${c.expires} = ?, ${c.pass} = ? WHERE id = ? AND user_id = ?`).run(expiresAt ?? null, passHash ?? null, id, userId).changes > 0;
|
||||||
|
if (changed && !outline && revealAt !== undefined) db.prepare('UPDATE projects SET share_reveal = ? WHERE id = ? AND user_id = ?').run(revealAt ?? null, id, userId);
|
||||||
|
return changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Public lookup for a link: the project plus the link's rules, or null for an unknown token. */
|
/** Public lookup for a link: the project plus the link's rules, or null for an unknown token. */
|
||||||
export function getSharedByToken(token, outline = false) {
|
export function getSharedByToken(token, outline = false) {
|
||||||
const c = shareCols(outline);
|
const c = shareCols(outline);
|
||||||
const row = db.prepare(`SELECT data, ${c.expires} AS expiresAt, ${c.pass} AS passHash FROM projects WHERE ${c.token} = ?`).get(token);
|
const row = db.prepare(`SELECT data, ${c.expires} AS expiresAt, ${c.pass} AS passHash, ${c.reveal} AS revealAt FROM projects WHERE ${c.token} = ?`).get(token);
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
try { return { project: JSON.parse(row.data), expiresAt: row.expiresAt ?? null, passHash: row.passHash ?? null }; } catch { return null; }
|
try { return { project: JSON.parse(row.data), expiresAt: row.expiresAt ?? null, passHash: row.passHash ?? null, revealAt: row.revealAt ?? null }; } catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Public lookup: the project data behind an outline link (callers must only ever send the outlines on). */
|
/** Public lookup: the project data behind an outline link (callers must only ever send the outlines on). */
|
||||||
|
|||||||
+24
-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.' };
|
||||||
@@ -28,6 +28,9 @@ export function cleanPrayer(raw, now = Date.now()) {
|
|||||||
createdAt: time(raw.createdAt, updatedAt), updatedAt,
|
createdAt: time(raw.createdAt, updatedAt), updatedAt,
|
||||||
answeredAt: Number.isFinite(raw.answeredAt) && raw.answeredAt > 0 ? raw.answeredAt : null,
|
answeredAt: Number.isFinite(raw.answeredAt) && raw.answeredAt > 0 ? raw.answeredAt : null,
|
||||||
answerNote: text(raw.answerNote, 1000).trim(),
|
answerNote: text(raw.answerNote, 1000).trim(),
|
||||||
|
status: raw.status === 'waiting' || raw.status === 'partial' ? raw.status : '',
|
||||||
|
pinned: raw.pinned === true,
|
||||||
|
rank: int(raw.rank, 0, 100_000) ? raw.rank : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,3 +37,8 @@ test('a deleted prayer is not brought back by an older copy, and old markers are
|
|||||||
test('open prayers are the ones not answered or deleted', () => {
|
test('open prayers are the ones not answered or deleted', () => {
|
||||||
expect(openPrayers([p('a'), p('b', { answeredAt: NOW }), { id: 'c', deleted: true, updatedAt: 1 }]).map((x) => x.id)).toEqual(['a']);
|
expect(openPrayers([p('a'), p('b', { answeredAt: NOW }), { id: 'c', deleted: true, updatedAt: 1 }]).map((x) => x.id)).toEqual(['a']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('pin, rank and status are kept when valid and dropped when not', () => {
|
||||||
|
expect(cleanPrayer(p('a', { pinned: true, rank: 2, status: 'partial' }), NOW)).toMatchObject({ pinned: true, rank: 2, status: 'partial' });
|
||||||
|
expect(cleanPrayer(p('a', { pinned: 'yes', rank: -4, status: 'bogus' }), NOW)).toMatchObject({ pinned: false, rank: null, status: '' });
|
||||||
|
});
|
||||||
@@ -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,152 @@
|
|||||||
|
// 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, hideUntilReveal, revealFromInput } 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, revealAt: settings.revealAt });
|
||||||
|
} 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;
|
||||||
|
// only a study link can hide its notes until a time; a past or empty time turns hiding off
|
||||||
|
const revealAt = outline ? null : ('revealAt' in body ? revealFromInput(body.revealAt) : settings.revealAt);
|
||||||
|
setShareSettings(req.params.id, userId, { expiresAt, passHash, revealAt }, outline);
|
||||||
|
res.json({ shareToken: token, expiresAt, hasPasscode: !!passHash, revealAt });
|
||||||
|
} 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 found = await openSharedLink(req, res, true);
|
||||||
|
if (found) res.json(buildOutlinePayload(found.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 found = await openSharedLink(req, res, false);
|
||||||
|
if (found) res.json(hideUntilReveal(found.project, found.revealAt));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('GET /api/share/:token error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to load shared project.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -25,3 +25,27 @@ export async function checkShareAccess({ expiresAt, passHash, supplied }, now =
|
|||||||
if (!(await bcrypt.compare(given, passHash))) return { ok: false, status: 401, needsPasscode: true, error: 'That passcode is not right.' };
|
if (!(await bcrypt.compare(given, passHash))) return { ok: false, status: 401, needsPasscode: true, error: 'That passcode is not right.' };
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "Hide my notes until…": before the reveal time a shared study shows only the passages and the discussion questions
|
||||||
|
// (no notes, outline, script, words or group notes), and says when the rest appears. The strip is done here, on the
|
||||||
|
// server, so the hidden text never reaches the browser. After the time (or with none set) the study is sent whole.
|
||||||
|
const OPEN_CHUNK_FIELDS = ['id', 'startVerse', 'endVerse', 'spilloverEndVerse', 'episodeNumber', 'episodeTitle', 'discussionQuestions'];
|
||||||
|
|
||||||
|
export function hideUntilReveal(project, revealAt, now = Date.now()) {
|
||||||
|
if (!revealAt || revealAt <= now || !project || typeof project !== 'object') return project;
|
||||||
|
return {
|
||||||
|
id: project.id, title: project.title, translation: project.translation, series: project.series, outline: [],
|
||||||
|
hiddenUntil: revealAt,
|
||||||
|
chapters: (Array.isArray(project.chapters) ? project.chapters : []).map((ch) => ({
|
||||||
|
book: ch.book, bookAbbrev: ch.bookAbbrev, chapter: ch.chapter, verses: ch.verses,
|
||||||
|
chunks: (Array.isArray(ch.chunks) ? ch.chunks : []).map((chunk) => Object.fromEntries(OPEN_CHUNK_FIELDS.filter((f) => f in chunk).map((f) => [f, chunk[f]]))),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// A reveal time from the browser -> ms in the future (up to a year), or null for "no hiding"
|
||||||
|
export function revealFromInput(value, now = Date.now()) {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!value || !Number.isFinite(n) || n <= now) return null;
|
||||||
|
return Math.min(n, now + 365 * 86_400_000);
|
||||||
|
}
|
||||||
@@ -31,3 +31,36 @@ test('an expired link is reported as expired even before the passcode is checked
|
|||||||
const passHash = await hashPasscode('x');
|
const passHash = await hashPasscode('x');
|
||||||
expect(await checkShareAccess({ passHash, expiresAt: NOW - 5, supplied: 'x' }, NOW)).toMatchObject({ status: 410 });
|
expect(await checkShareAccess({ passHash, expiresAt: NOW - 5, supplied: 'x' }, NOW)).toMatchObject({ status: 410 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { hideUntilReveal, revealFromInput } from './shareLinks.js';
|
||||||
|
|
||||||
|
const study = () => ({
|
||||||
|
id: 's1', title: 'John', translation: 'BSB', outline: [{ id: 'n', text: 'Point' }],
|
||||||
|
chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: [{ number: 16, text: 'For God so loved.' }],
|
||||||
|
chunks: [{ id: 'c', startVerse: 16, endVerse: 18, discussionQuestions: 'Why?', observation: 'secret', interpretation: 'secret', application: 'secret', finalScript: 'secret', generalNotes: 'secret', greekWords: [{ id: 'w' }], teachBack: 'secret', recalls: [{ text: 'secret' }] }] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
test('before the reveal time a shared study keeps the passages and questions and drops every note', () => {
|
||||||
|
const now = 1_000;
|
||||||
|
const out = hideUntilReveal(study(), now + 5_000, now);
|
||||||
|
expect(out.hiddenUntil).toBe(now + 5_000);
|
||||||
|
expect(out.outline).toEqual([]);
|
||||||
|
expect(out.chapters[0].verses).toHaveLength(1);
|
||||||
|
expect(out.chapters[0].chunks[0]).toEqual({ id: 'c', startVerse: 16, endVerse: 18, discussionQuestions: 'Why?' });
|
||||||
|
expect(JSON.stringify(out)).not.toContain('secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('after the reveal time, or with none set, the whole study is sent', () => {
|
||||||
|
const full = study();
|
||||||
|
expect(hideUntilReveal(full, 500, 1_000)).toBe(full);
|
||||||
|
expect(hideUntilReveal(full, null, 1_000)).toBe(full);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a reveal time must be in the future, and no more than a year away', () => {
|
||||||
|
const now = 1_000_000;
|
||||||
|
expect(revealFromInput(null, now)).toBeNull();
|
||||||
|
expect(revealFromInput(now - 1, now)).toBeNull();
|
||||||
|
expect(revealFromInput('nope', now)).toBeNull();
|
||||||
|
expect(revealFromInput(now + 60_000, now)).toBe(now + 60_000);
|
||||||
|
expect(revealFromInput(now + 10 * 365 * 86_400_000, now)).toBe(now + 365 * 86_400_000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// "Where else does this word appear?": every verse that uses a Strong's number, grouped by book. Built once, in
|
||||||
|
// memory, from the interlinear files the Reader already ships (public/interlinear, or dist/interlinear in a build),
|
||||||
|
// the first time anyone asks. Greek (G…) and Hebrew (H…) numbers both work.
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import { readdir, readFile } from 'fs/promises';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
import { bookNameFor } from './bibleApi.js';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
// Strong's numbers come as "G4102", "g4102", "H7225" or sometimes with a letter suffix ("H1234a").
|
||||||
|
export function normalizeStrongs(input) {
|
||||||
|
const m = String(input ?? '').trim().match(/^([GH])0*(\d{1,5})[a-z]?$/i);
|
||||||
|
return m ? `${m[1].toUpperCase()}${Number(m[2])}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One book's interlinear file -> adds each verse it uses a number in to the index (once per verse).
|
||||||
|
export function indexBook(index, book, data) {
|
||||||
|
for (const [chapter, verses] of Object.entries(data ?? {})) {
|
||||||
|
for (const [verse, words] of Object.entries(verses ?? {})) {
|
||||||
|
const seen = new Set();
|
||||||
|
for (const w of Array.isArray(words) ? words : []) {
|
||||||
|
const s = normalizeStrongs(w?.s);
|
||||||
|
if (!s || seen.has(s)) continue;
|
||||||
|
seen.add(s);
|
||||||
|
let entry = index.get(s);
|
||||||
|
if (!entry) { entry = { count: 0, books: new Map() }; index.set(s, entry); }
|
||||||
|
let refs = entry.books.get(book);
|
||||||
|
if (!refs) { refs = []; entry.books.set(book, refs); }
|
||||||
|
refs.push([Number(chapter), Number(verse)]);
|
||||||
|
entry.count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The answer for one number: { strongs, total, books: [{ book, name, count, refs: [[chapter, verse], ...] }] },
|
||||||
|
// books in the order the index was built (Bible order when files are indexed in canon order).
|
||||||
|
export function occurrences(index, strongs) {
|
||||||
|
const key = normalizeStrongs(strongs);
|
||||||
|
const entry = key && index.get(key);
|
||||||
|
if (!entry) return { strongs: key, total: 0, books: [] };
|
||||||
|
const books = [...entry.books].map(([book, refs]) => ({
|
||||||
|
book, name: bookNameFor(book) ?? book, count: refs.length,
|
||||||
|
refs: refs.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]),
|
||||||
|
}));
|
||||||
|
return { strongs: key, total: entry.count, books };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canon order so the books come back Genesis → Revelation, whatever order the directory lists them.
|
||||||
|
const CANON = ['GEN','EXO','LEV','NUM','DEU','JOS','JDG','RUT','1SA','2SA','1KI','2KI','1CH','2CH','EZR','NEH','EST','JOB','PSA','PRO','ECC','SNG','ISA','JER','LAM','EZK','DAN','HOS','JOL','AMO','OBA','JON','MIC','NAM','HAB','ZEP','HAG','ZEC','MAL','MAT','MRK','LUK','JHN','ACT','ROM','1CO','2CO','GAL','EPH','PHP','COL','1TH','2TH','1TI','2TI','TIT','PHM','HEB','JAS','1PE','2PE','1JN','2JN','3JN','JUD','REV'];
|
||||||
|
|
||||||
|
export async function buildIndex(dir) {
|
||||||
|
const index = new Map();
|
||||||
|
const files = new Set(await readdir(dir));
|
||||||
|
for (const book of CANON) {
|
||||||
|
if (!files.has(`${book}.json`)) continue;
|
||||||
|
try { indexBook(index, book, JSON.parse(await readFile(join(dir, `${book}.json`), 'utf8'))); } catch { /* a damaged file just leaves that book out */ }
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
function interlinearDir() {
|
||||||
|
return [join(__dirname, '..', 'dist', 'interlinear'), join(__dirname, '..', 'public', 'interlinear')].find(existsSync) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pending = null;
|
||||||
|
const getIndex = () => {
|
||||||
|
if (!pending) {
|
||||||
|
const dir = interlinearDir();
|
||||||
|
pending = (dir ? buildIndex(dir) : Promise.resolve(new Map())).catch((err) => { pending = null; throw err; });
|
||||||
|
}
|
||||||
|
return pending;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function registerStrongsRoutes(app, requireAuth) {
|
||||||
|
// GET /api/strongs/:number/occurrences
|
||||||
|
app.get('/api/strongs/:number/occurrences', requireAuth, async (req, res) => {
|
||||||
|
const key = normalizeStrongs(req.params.number);
|
||||||
|
if (!key) return res.status(400).json({ error: 'Use a Strong’s number like G4102 or H7225.' });
|
||||||
|
try {
|
||||||
|
res.json(occurrences(await getIndex(), key));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('GET /api/strongs occurrences error:', err);
|
||||||
|
res.status(500).json({ error: 'Could not look up that word.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { test, expect } from 'vitest';
|
||||||
|
import { indexBook, normalizeStrongs, occurrences } from './strongs.js';
|
||||||
|
|
||||||
|
test('normalizeStrongs accepts the usual spellings and rejects the rest', () => {
|
||||||
|
expect(normalizeStrongs('g4102')).toBe('G4102');
|
||||||
|
expect(normalizeStrongs(' H07225 ')).toBe('H7225');
|
||||||
|
expect(normalizeStrongs('H1234a')).toBe('H1234');
|
||||||
|
expect(normalizeStrongs('4102')).toBeNull();
|
||||||
|
expect(normalizeStrongs('')).toBeNull();
|
||||||
|
expect(normalizeStrongs(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('occurrences groups verses by book and counts a verse once however often the word repeats', () => {
|
||||||
|
const index = new Map();
|
||||||
|
indexBook(index, 'JHN', {
|
||||||
|
3: { 16: [{ s: 'G25' }, { s: 'G25' }, { s: 'G2316' }], 17: [{ s: 'G2316' }] },
|
||||||
|
1: { 1: [{ s: 'G3056' }, { s: 'G2316' }] },
|
||||||
|
});
|
||||||
|
indexBook(index, 'ROM', { 5: { 8: [{ s: 'G25' }, { s: 'G2316' }] } });
|
||||||
|
|
||||||
|
const love = occurrences(index, 'g25');
|
||||||
|
expect(love.total).toBe(2);
|
||||||
|
expect(love.books.map((b) => [b.book, b.name, b.count])).toEqual([['JHN', 'John', 1], ['ROM', 'Romans', 1]]);
|
||||||
|
|
||||||
|
const god = occurrences(index, 'G2316');
|
||||||
|
expect(god.total).toBe(4);
|
||||||
|
expect(god.books[0].refs).toEqual([[1, 1], [3, 16], [3, 17]]); // sorted by chapter then verse
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unknown or invalid number comes back empty', () => {
|
||||||
|
const index = new Map();
|
||||||
|
expect(occurrences(index, 'G99999')).toEqual({ strongs: 'G99999', total: 0, books: [] });
|
||||||
|
expect(occurrences(index, 'nonsense')).toEqual({ strongs: null, total: 0, books: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('malformed interlinear data is skipped, not thrown', () => {
|
||||||
|
const index = new Map();
|
||||||
|
expect(() => indexBook(index, 'GEN', { 1: { 1: null, 2: 'x', 3: [null, { s: 5 }, { s: 'H430' }] } })).not.toThrow();
|
||||||
|
expect(occurrences(index, 'H430').total).toBe(1);
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -115,7 +115,7 @@ describe('Search palette (⌘K)', () => {
|
|||||||
await user.type(await paletteBox(), 'abounds');
|
await user.type(await paletteBox(), 'abounds');
|
||||||
const hit = await screen.findByRole('option', { name: /Titus 1:1/ });
|
const hit = await screen.findByRole('option', { name: /Titus 1:1/ });
|
||||||
await user.click(hit);
|
await user.click(hit);
|
||||||
expect(await screen.findByText(/Your notes/i)).toBeInTheDocument();
|
expect(await screen.findByRole('textbox', { name: 'Observe' })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Escape closes it', async () => {
|
test('Escape closes it', async () => {
|
||||||
|
|||||||
+18
-24
@@ -11,6 +11,8 @@ import Tooltips from './components/Tooltips.jsx';
|
|||||||
import { useAudio } from './hooks/useAudio.js';
|
import { useAudio } from './hooks/useAudio.js';
|
||||||
import { useAuth } from './hooks/useAuth.js';
|
import { useAuth } from './hooks/useAuth.js';
|
||||||
import { useBibleReader } from './hooks/useBibleReader.js';
|
import { useBibleReader } from './hooks/useBibleReader.js';
|
||||||
|
import { lastTemplateId, planStudyProject } from './utils/planStudy.js';
|
||||||
|
import { isAudienceWindow } from './utils/presentSync.js';
|
||||||
import { useProjectLibrary } from './hooks/useProjectLibrary.js';
|
import { useProjectLibrary } from './hooks/useProjectLibrary.js';
|
||||||
import { useHistoryNav } from './hooks/useHistoryNav.js';
|
import { useHistoryNav } from './hooks/useHistoryNav.js';
|
||||||
import { usePrayers } from './hooks/usePrayers.js';
|
import { usePrayers } from './hooks/usePrayers.js';
|
||||||
@@ -37,6 +39,8 @@ const ImportPage = lazy(() => import('./pages/ImportPage.jsx'));
|
|||||||
const ReaderPage = lazy(() => import('./pages/ReaderPage.jsx'));
|
const ReaderPage = lazy(() => import('./pages/ReaderPage.jsx'));
|
||||||
const SettingsPage = lazy(() => import('./pages/SettingsPage.jsx'));
|
const SettingsPage = lazy(() => import('./pages/SettingsPage.jsx'));
|
||||||
const SetupPage = lazy(() => import('./pages/SetupPage.jsx'));
|
const SetupPage = lazy(() => import('./pages/SetupPage.jsx'));
|
||||||
|
const AudienceView = lazy(() => import('./components/AudienceView.jsx'));
|
||||||
|
const InsightsPage = lazy(() => import('./pages/InsightsPage.jsx'));
|
||||||
const StudyPage = lazy(() => import('./pages/StudyPage.jsx'));
|
const StudyPage = lazy(() => import('./pages/StudyPage.jsx'));
|
||||||
const OutlinePage = lazy(() => import('./pages/OutlinePage.jsx'));
|
const OutlinePage = lazy(() => import('./pages/OutlinePage.jsx'));
|
||||||
const ScriptPage = lazy(() => import('./pages/ScriptPage.jsx'));
|
const ScriptPage = lazy(() => import('./pages/ScriptPage.jsx'));
|
||||||
@@ -166,8 +170,8 @@ const AppInner = () => {
|
|||||||
// The browser tab and history show where you are ("Romans 8 · Bible Study", "Luke — Outline · Bible Study")
|
// The browser tab and history show where you are ("Romans 8 · Bible Study", "Luke — Outline · Bible Study")
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const names = {
|
const names = {
|
||||||
home: 'My Studies', setup: 'Chunks', study: 'Study', outline: 'Outline', script: 'Script', export: 'Export',
|
home: 'My Studies', setup: 'Chunks', study: 'Study', outline: 'Outline', script: 'Script', export: 'Finish',
|
||||||
import: 'Import', pronunciation: 'Pronunciation Guide', settings: 'Settings', admin: 'Admin', 'reading-plans': 'Reading Plans', prayer: 'Prayer journal',
|
import: 'Import', pronunciation: 'Pronunciation Guide', settings: 'Settings', admin: 'Admin', 'reading-plans': 'Reading Plans', prayer: 'Prayer journal', insights: 'Insights',
|
||||||
};
|
};
|
||||||
let page = names[currentPage] ?? '';
|
let page = names[currentPage] ?? '';
|
||||||
if (currentPage === 'reader') {
|
if (currentPage === 'reader') {
|
||||||
@@ -355,12 +359,13 @@ const AppInner = () => {
|
|||||||
setProject((current) => (current ? updater(current) : current));
|
setProject((current) => (current ? updater(current) : current));
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateChunk = (chunkId, patch) => {
|
// `touch: false` saves a change without counting it as an edit (so it doesn't reset "last edited" for recalls and summaries).
|
||||||
|
const updateChunk = (chunkId, patch, { touch = true } = {}) => {
|
||||||
updateProject((current) => ({
|
updateProject((current) => ({
|
||||||
...current,
|
...current,
|
||||||
chapters: current.chapters.map((ch) => ({
|
chapters: current.chapters.map((ch) => ({
|
||||||
...ch,
|
...ch,
|
||||||
chunks: ch.chunks.map((c) => (c.id === chunkId ? { ...c, ...patch, editedAt: Date.now() } : c)),
|
chunks: ch.chunks.map((c) => (c.id === chunkId ? { ...c, ...patch, ...(touch ? { editedAt: Date.now() } : {}) } : c)),
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
@@ -421,29 +426,13 @@ const AppInner = () => {
|
|||||||
const studyReadingPlanDay = async (dayRefs, title) => {
|
const studyReadingPlanDay = async (dayRefs, title) => {
|
||||||
setStudyBuildBusy(true);
|
setStudyBuildBusy(true);
|
||||||
try {
|
try {
|
||||||
|
const lastTemplate = lastTemplateId(); // the notes template you picked last time you started a study by hand
|
||||||
|
const templateDef = reader.userTemplates?.find((t) => t.id === lastTemplate);
|
||||||
const chapters = await Promise.all(dayRefs.map(async ({ book, chapter }) => {
|
const chapters = await Promise.all(dayRefs.map(async ({ book, chapter }) => {
|
||||||
const data = await fetchStudyChapter('BSB', book, chapter);
|
const data = await fetchStudyChapter('BSB', book, chapter);
|
||||||
const verses = parseBibleChapter(data);
|
return { bookAbbrev: book, bookName: bookOptions.find((b) => b.abbrev === book)?.name ?? book, chapter, verses: parseBibleChapter(data) };
|
||||||
const bookName = bookOptions.find((b) => b.abbrev === book)?.name ?? book;
|
|
||||||
const chunk = {
|
|
||||||
id: makeId(),
|
|
||||||
startVerse: verses[0]?.number ?? 1,
|
|
||||||
endVerse: verses.at(-1)?.number ?? 1,
|
|
||||||
spilloverEndVerse: null,
|
|
||||||
observation: '', interpretation: '', application: '',
|
|
||||||
crossReferences: [], greekWords: [], generalNotes: '',
|
|
||||||
episodeNumber: '', episodeTitle: '', finalScript: '', tags: [],
|
|
||||||
};
|
|
||||||
return { book: bookName, bookAbbrev: book, chapter: String(chapter), verses, chunks: [chunk] };
|
|
||||||
}));
|
}));
|
||||||
const newProject = {
|
const newProject = planStudyProject({ chapters, title, templateId: lastTemplate, templateDef, makeId });
|
||||||
id: makeId(),
|
|
||||||
title: title || 'Reading Plan Study',
|
|
||||||
translation: 'BSB',
|
|
||||||
lastEdited: Date.now(),
|
|
||||||
selectedChunkId: chapters[0]?.chunks[0]?.id ?? null,
|
|
||||||
chapters,
|
|
||||||
};
|
|
||||||
setProject(newProject);
|
setProject(newProject);
|
||||||
setCurrentPage('study');
|
setCurrentPage('study');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -584,6 +573,9 @@ function AppRouter() {
|
|||||||
// A read-only outline link: no sign-in, nothing editable
|
// A read-only outline link: no sign-in, nothing editable
|
||||||
if (sharedOutlineToken) return <SharedOutlineView token={sharedOutlineToken} />;
|
if (sharedOutlineToken) return <SharedOutlineView token={sharedOutlineToken} />;
|
||||||
|
|
||||||
|
// The second window for presenting on a projector: no sign-in, it only shows what the presenter window sends.
|
||||||
|
if (isAudienceWindow()) return <Suspense fallback={null}><AudienceView /></Suspense>;
|
||||||
|
|
||||||
if (sharedViewToken) {
|
if (sharedViewToken) {
|
||||||
if (needsPasscode) return <SharedPasscodeForm dark error={passcodeError} busy={checking} onSubmit={submitPasscode} />;
|
if (needsPasscode) return <SharedPasscodeForm dark error={passcodeError} busy={checking} onSubmit={submitPasscode} />;
|
||||||
if (sharedError) {
|
if (sharedError) {
|
||||||
@@ -606,6 +598,7 @@ function AppRouter() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-dvh bg-slate-100">
|
<div className="min-h-dvh bg-slate-100">
|
||||||
<div className="border-b border-slate-200 bg-slate-900 px-4 py-3 text-center text-xs text-slate-300">
|
<div className="border-b border-slate-200 bg-slate-900 px-4 py-3 text-center text-xs text-slate-300">
|
||||||
|
{sharedProject.hiddenUntil ? `Notes appear ${new Date(sharedProject.hiddenUntil).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })}. For now: the passages and questions. ` : ''}
|
||||||
🔗 Read-only shared view —{' '}
|
🔗 Read-only shared view —{' '}
|
||||||
<a href="/" className="underline hover:text-white">go to Bible Study Project</a>
|
<a href="/" className="underline hover:text-white">go to Bible Study Project</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -629,6 +622,7 @@ function AppRouter() {
|
|||||||
if (currentPage === 'prayer') return <PrayerPage />;
|
if (currentPage === 'prayer') return <PrayerPage />;
|
||||||
if (currentPage === 'reader') return <ReaderPage />;
|
if (currentPage === 'reader') return <ReaderPage />;
|
||||||
if (currentPage === 'import') return <ImportPage />;
|
if (currentPage === 'import') return <ImportPage />;
|
||||||
|
if (currentPage === 'insights') return <InsightsPage />;
|
||||||
if (currentPage === 'study') return <StudyPage />;
|
if (currentPage === 'study') return <StudyPage />;
|
||||||
if (currentPage === 'outline') return <OutlinePage />;
|
if (currentPage === 'outline') return <OutlinePage />;
|
||||||
if (currentPage === 'script') return <ScriptPage />;
|
if (currentPage === 'script') return <ScriptPage />;
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// The audience window for two-screen presenting: drag it to the projector or second display and press F (or click)
|
||||||
|
// for full screen. It only draws what the presenter window sends, which never includes private notes.
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import StepFace from './StepFace.jsx';
|
||||||
|
import { openChannel } from '../utils/presentSync.js';
|
||||||
|
|
||||||
|
export default function AudienceView() {
|
||||||
|
const [state, setState] = useState(null);
|
||||||
|
const [ended, setEnded] = useState(false);
|
||||||
|
const [hint, setHint] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = 'Audience display';
|
||||||
|
const channel = openChannel();
|
||||||
|
if (!channel) return undefined;
|
||||||
|
channel.onmessage = (e) => {
|
||||||
|
if (e.data?.type === 'state') { setState(e.data); setEnded(false); }
|
||||||
|
else if (e.data?.type === 'end') setEnded(true);
|
||||||
|
};
|
||||||
|
channel.postMessage({ type: 'hello' }); // ask a presenter already running to send what it is showing
|
||||||
|
return () => channel.close();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fullscreen = () => { if (document.fullscreenElement) document.exitFullscreen?.(); else document.documentElement.requestFullscreen?.().catch(() => {}); };
|
||||||
|
const onKey = (e) => { if (e.key === 'f' || e.key === 'F') fullscreen(); };
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
const timer = window.setTimeout(() => setHint(false), 8000);
|
||||||
|
return () => { window.removeEventListener('keydown', onKey); window.clearTimeout(timer); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dark = state?.dark ?? true;
|
||||||
|
const colors = dark ? 'bg-[#111] text-[#f2efe8]' : 'bg-[#faf7f0] text-[#2b2620]';
|
||||||
|
const fontSize = `${state?.fontPx ?? 40}px`;
|
||||||
|
let body;
|
||||||
|
if (!state || ended) {
|
||||||
|
body = <p className="text-center text-lg opacity-60">{ended ? 'The presentation has ended.' : 'Waiting for the presenter…'}</p>;
|
||||||
|
} else if (state.blank) {
|
||||||
|
body = null;
|
||||||
|
} else if (state.mode === 'outline' && state.step) {
|
||||||
|
body = <StepFace step={state.step} index={state.index} total={state.total} fontSize={fontSize} />;
|
||||||
|
} else {
|
||||||
|
body = (
|
||||||
|
<div className="text-center font-serif" style={{ fontSize }}>
|
||||||
|
<h1 className="font-semibold">{state.title}</h1>
|
||||||
|
{state.subtitle && <p className="mt-3 text-[0.55em] opacity-70">{state.subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={`fixed inset-0 flex cursor-none items-center justify-center overflow-hidden px-[6vw] py-[6dvh] ${state?.blank ? 'bg-black' : colors}`} onClick={() => document.documentElement.requestFullscreen?.().catch(() => {})}>
|
||||||
|
{body}
|
||||||
|
{hint && !state?.blank && <p className="fixed bottom-3 left-0 right-0 text-center font-sans text-xs opacity-40">Press F or click for full screen</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// Export → Co-teachers: let other accounts edit this study with you. You make a single-use invite link;
|
// Finish → Co-teachers: let other accounts edit this study with you. You make a single-use invite link;
|
||||||
// whoever opens it (signed in) can edit the study, and you can remove them any time. Edits from both of you
|
// whoever opens it (signed in) can edit the study, and you can remove them any time. Edits from both of you
|
||||||
// sync through the account; if you both change the same study, "Combine both" keeps everything.
|
// sync through the account; if you both change the same study, "Combine both" keeps everything.
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Home card: your five minutes for today. A short prompt on a passage from your studies, a five-minute clock, and a
|
||||||
|
// box to write in. What you write is added to that passage's notes. Pasting is turned off here on purpose.
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useApp } from '../context/AppContext.js';
|
||||||
|
import { loadProjectById } from '../utils/projectStorage.js';
|
||||||
|
import { FIELD_NAMES, MINUTES, dailyPatch, pickDaily } from '../utils/dailyFive.js';
|
||||||
|
import { formatClock } from '../utils/present.js';
|
||||||
|
|
||||||
|
const box = 'mb-4 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-3 text-sm';
|
||||||
|
|
||||||
|
export default function DailyFive() {
|
||||||
|
const { projectIndex, saveChunkPatch } = useApp();
|
||||||
|
const [step, setStep] = useState('card'); // card -> write -> done
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
const [left, setLeft] = useState(MINUTES * 60);
|
||||||
|
const [pasteTried, setPasteTried] = useState(false);
|
||||||
|
const pick = useMemo(() => pickDaily(projectIndex.map((e) => loadProjectById(e.id)).filter(Boolean)), [projectIndex]);
|
||||||
|
const started = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (step !== 'write') return undefined;
|
||||||
|
started.current = Date.now();
|
||||||
|
const t = window.setInterval(() => setLeft(Math.max(0, MINUTES * 60 - Math.round((Date.now() - started.current) / 1000))), 1000);
|
||||||
|
return () => window.clearInterval(t);
|
||||||
|
}, [step]);
|
||||||
|
|
||||||
|
if (step === 'done') return <div role="status" className={box}>Saved to your notes. Same time tomorrow.</div>;
|
||||||
|
if (!pick) return null;
|
||||||
|
const { prompt } = pick;
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
saveChunkPatch(pick.projectId, pick.chunk.id, dailyPatch(pick.chunk, prompt.field, text));
|
||||||
|
setStep('done');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (step === 'card') {
|
||||||
|
return (
|
||||||
|
<div className={`${box} flex flex-wrap items-center justify-between gap-3`}>
|
||||||
|
<span><span className="font-medium">Your five minutes:</span> {pick.reference} <span className="text-[var(--r-sub)]">· {FIELD_NAMES[prompt.field]} · {pick.projectTitle}</span></span>
|
||||||
|
<button type="button" onClick={() => { setText(''); setLeft(MINUTES * 60); setPasteTried(false); 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="Your five minutes" className={box}>
|
||||||
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
|
<p className="font-medium">{pick.reference} <span className="font-normal text-[var(--r-sub)]">· {FIELD_NAMES[prompt.field]}</span></p>
|
||||||
|
<span role="timer" aria-label="Time left" className={`font-mono text-sm tabular-nums ${left === 0 ? 'text-[var(--r-accent)]' : 'text-[var(--r-sub)]'}`}>{left === 0 ? 'Time’s up, finish your thought' : formatClock(left)}</span>
|
||||||
|
</div>
|
||||||
|
{pick.verses.length > 0 && (
|
||||||
|
<div className="mt-2 max-h-40 overflow-y-auto rounded-lg bg-[var(--r-bg)] p-3 font-serif text-[13px] leading-relaxed">
|
||||||
|
{pick.verses.map((v) => <p key={v.number}><sup className="mr-1 font-sans text-[var(--r-accent)]">{v.number}</sup>{v.text}</p>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="mt-2 text-xs text-[var(--r-sub)]">{prompt.text}</p>
|
||||||
|
<textarea aria-label="Your five minutes" value={text} onChange={(e) => setText(e.target.value)} rows={6} autoFocus
|
||||||
|
onPaste={(e) => { e.preventDefault(); setPasteTried(true); }}
|
||||||
|
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" />
|
||||||
|
{pasteTried && <p role="note" className="mt-1 text-xs text-[var(--r-accent)]">Pasting is off here. Type it in your own words; that’s what the five minutes are for.</p>}
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<button type="button" disabled={!text.trim()} onClick={save} className="rounded-full bg-[var(--r-text)] px-3 py-1 text-xs font-medium text-[var(--r-bg)] disabled:opacity-40">Save to my notes</button>
|
||||||
|
<button type="button" onClick={() => setStep('card')} className="text-xs text-[var(--r-sub)] hover:underline">Not now</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { vi, test, expect, beforeEach } from 'vitest';
|
||||||
|
import { AppContext } from '../context/AppContext.js';
|
||||||
|
import { saveProjectToStorage } from '../utils/projectStorage.js';
|
||||||
|
import DailyFive from './DailyFive.jsx';
|
||||||
|
import { promptFor } from '../utils/dailyFive.js';
|
||||||
|
|
||||||
|
const project = () => ({
|
||||||
|
id: 'p1', title: 'John', translation: 'BSB', lastEdited: 1,
|
||||||
|
chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: [{ number: 16, text: 'For God so loved the world.' }],
|
||||||
|
chunks: [{ id: 'c1', startVerse: 16, endVerse: 16, observation: '', interpretation: '', application: '', editedAt: 1 }] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
localStorage.clear();
|
||||||
|
saveProjectToStorage(project());
|
||||||
|
const saveChunkPatch = vi.fn();
|
||||||
|
const value = { projectIndex: [{ id: 'p1' }], saveChunkPatch };
|
||||||
|
render(<AppContext.Provider value={value}><DailyFive /></AppContext.Provider>);
|
||||||
|
return { saveChunkPatch, user: userEvent.setup() };
|
||||||
|
}
|
||||||
|
beforeEach(() => localStorage.clear());
|
||||||
|
|
||||||
|
test('the card names a passage and what kind of note the five minutes are for', () => {
|
||||||
|
setup();
|
||||||
|
expect(screen.getByText(/your five minutes/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/John 3:16/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pasting is blocked, and what you type is saved to the matching note', async () => {
|
||||||
|
const { saveChunkPatch, user } = setup();
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Start' }));
|
||||||
|
expect(screen.getByText('For God so loved the world.')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('timer')).toHaveTextContent('5:00');
|
||||||
|
const box = screen.getByRole('textbox', { name: /your five minutes/i });
|
||||||
|
fireEvent.paste(box, { clipboardData: { getData: () => 'text from somewhere else' } });
|
||||||
|
expect(screen.getByText(/pasting is off here/i)).toBeInTheDocument();
|
||||||
|
expect(box).toHaveValue('');
|
||||||
|
expect(screen.getByRole('button', { name: /save to my notes/i })).toBeDisabled();
|
||||||
|
await user.type(box, 'God gave.');
|
||||||
|
await user.click(screen.getByRole('button', { name: /save to my notes/i }));
|
||||||
|
const field = promptFor().field;
|
||||||
|
expect(saveChunkPatch).toHaveBeenCalledWith('p1', 'c1', expect.objectContaining({ [field]: 'God gave.', fiveMinutes: [expect.objectContaining({ field })] }));
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent(/saved to your notes/i);
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// A person's family: the relatives named in their record (click one to read about them), and a tree of ancestors and
|
||||||
|
// descendants a few generations out. From Theographic Bible Metadata.
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { loadPerson } from '../hooks/useVerseStudy.js';
|
||||||
|
import { relatives, walk } from '../utils/family.js';
|
||||||
|
|
||||||
|
function Branch({ node, onOpen }) {
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<button type="button" onClick={() => onOpen(node.id)} className="text-[var(--r-accent)] hover:underline">{node.name || node.id}</button>
|
||||||
|
{node.more && <span className="ml-1 text-[var(--r-sub)]" title="More people beyond this point">…</span>}
|
||||||
|
{node.kids.length > 0 && <ul className="ml-4 border-l border-[var(--r-line)] pl-3">{node.kids.map((k) => <Branch key={k.id} node={k} onOpen={onOpen} />)}</ul>}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tree({ id, onOpen }) {
|
||||||
|
const [state, setState] = useState({ loading: true });
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
Promise.all([walk(loadPerson, id, { direction: 'up', depth: 3, max: 30 }), walk(loadPerson, id, { direction: 'down', depth: 2, max: 40 })])
|
||||||
|
.then(([up, down]) => { if (live) setState({ up, down }); })
|
||||||
|
.catch(() => { if (live) setState({ error: true }); });
|
||||||
|
return () => { live = false; };
|
||||||
|
}, [id]);
|
||||||
|
if (state.loading) return <p className="mt-1 text-[var(--r-sub)]">Loading the family…</p>;
|
||||||
|
if (state.error) return <p className="mt-1 text-[var(--r-sub)]">The family tree couldn’t be loaded right now.</p>;
|
||||||
|
const block = (title, node, empty) => (
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-[var(--r-text)]">{title}</p>
|
||||||
|
{node.kids.length ? <ul className="mt-1 space-y-0.5">{node.kids.map((k) => <Branch key={k.id} node={k} onOpen={onOpen} />)}</ul> : <p className="text-[var(--r-sub)]">{empty}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return <div className="mt-2 grid gap-3 sm:grid-cols-2">{block('Ancestors', state.up, 'None recorded.')}{block('Descendants', state.down, 'None recorded.')}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `person` is the record; `renderPerson(id)` shows another person's details inside this one when a name is clicked.
|
||||||
|
export default function Family({ person, renderPerson }) {
|
||||||
|
const groups = relatives(person);
|
||||||
|
const [shown, setShown] = useState(null); // a relative opened from a name or the tree
|
||||||
|
const [treeOpen, setTreeOpen] = useState(false); // the tree's records are only fetched once it is opened
|
||||||
|
if (groups.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="mt-2 space-y-1.5 border-t border-[var(--r-line)] pt-2" aria-label="Family">
|
||||||
|
{groups.map((g) => (
|
||||||
|
<p key={g.label} className="flex flex-wrap items-baseline gap-x-1.5">
|
||||||
|
<span className="w-16 shrink-0 text-[11px] uppercase tracking-wide">{g.label}</span>
|
||||||
|
{g.people.map((p) => (
|
||||||
|
<button key={p.id} type="button" onClick={() => setShown(shown === p.id ? null : p.id)} aria-pressed={shown === p.id}
|
||||||
|
className={`rounded-full border px-2 py-0.5 ${shown === p.id ? 'border-[var(--r-accent)] text-[var(--r-text)]' : 'border-[var(--r-line)] text-[var(--r-accent)] hover:bg-black/5'}`}>{p.name}</button>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
<details onToggle={(e) => setTreeOpen(e.currentTarget.open)}>
|
||||||
|
<summary className="cursor-pointer text-[var(--r-accent)]">Family tree</summary>
|
||||||
|
{treeOpen && <Tree id={person.id} onOpen={(id) => setShown(id)} />}
|
||||||
|
</details>
|
||||||
|
{shown && <div className="rounded-lg bg-[var(--r-bar)] p-2">{renderPerson(shown)}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { render, screen, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { vi, test, expect, beforeEach } from 'vitest';
|
||||||
|
import Family from './FamilyTree.jsx';
|
||||||
|
|
||||||
|
const ref = (id, name) => ({ id, name, type: 'people' });
|
||||||
|
const RECORDS = {
|
||||||
|
fam_seth: { id: 'fam_seth', name: 'Seth', father: [ref('fam_adam', 'Adam')], mother: [ref('fam_eve', 'Eve')], children: [ref('fam_enos', 'Enos')], siblings: [ref('fam_abel', 'Abel')] },
|
||||||
|
fam_adam: { id: 'fam_adam', name: 'Adam', children: [ref('fam_seth', 'Seth')] },
|
||||||
|
fam_eve: { id: 'fam_eve', name: 'Eve', children: [ref('fam_seth', 'Seth')] },
|
||||||
|
fam_enos: { id: 'fam_enos', name: 'Enos', father: [ref('fam_seth', 'Seth')] },
|
||||||
|
fam_abel: { id: 'fam_abel', name: 'Abel' },
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async (url) => {
|
||||||
|
const id = String(url).match(/people\/(.+)\.json/)?.[1];
|
||||||
|
return RECORDS[id] ? { ok: true, json: async () => ({ person: RECORDS[id] }) } : { ok: false, json: async () => ({}) };
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the relatives named in the record are listed, and clicking one opens them', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<Family person={RECORDS.fam_seth} renderPerson={(id) => <div>details for {id}</div>} />);
|
||||||
|
const family = screen.getByLabelText('Family');
|
||||||
|
for (const name of ['Adam', 'Eve', 'Abel', 'Enos']) expect(within(family).getByRole('button', { name })).toBeInTheDocument();
|
||||||
|
await user.click(within(family).getByRole('button', { name: 'Adam' }));
|
||||||
|
expect(screen.getByText('details for fam_adam')).toBeInTheDocument();
|
||||||
|
await user.click(within(family).getByRole('button', { name: 'Adam' }));
|
||||||
|
expect(screen.queryByText('details for fam_adam')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the family tree shows ancestors and descendants', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<Family person={RECORDS.fam_seth} renderPerson={(id) => <div>details for {id}</div>} />);
|
||||||
|
await user.click(screen.getByText('Family tree'));
|
||||||
|
expect(await screen.findByText('Ancestors')).toBeInTheDocument();
|
||||||
|
const tree = screen.getByText('Ancestors').closest('div').parentElement;
|
||||||
|
expect(within(tree).getAllByRole('button', { name: 'Adam' }).length).toBeGreaterThan(0);
|
||||||
|
expect(within(tree).getAllByRole('button', { name: 'Enos' }).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a person with no recorded family shows nothing', () => {
|
||||||
|
const { container } = render(<Family person={{ id: 'x', name: 'X' }} renderPerson={() => null} />);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// What an original-language word's grammar label means, in plain English.
|
||||||
|
import { grammarNotes } from '../utils/grammar.js';
|
||||||
|
|
||||||
|
export default function GrammarPanel({ label, className = '' }) {
|
||||||
|
const notes = grammarNotes(label);
|
||||||
|
if (!label) return null;
|
||||||
|
return (
|
||||||
|
<div className={`rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-2.5 font-sans text-xs ${className}`} aria-label="Grammar">
|
||||||
|
<p className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Grammar · {label}</p>
|
||||||
|
{notes.length > 0 && (
|
||||||
|
<ul className="mt-1.5 space-y-1.5">
|
||||||
|
{notes.map((n) => (
|
||||||
|
<li key={n.term}><span className="font-medium">{n.term}.</span> <span className="text-[var(--r-sub)]">{n.meaning}</span></li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
// Export → Lesson handout: a printable page for each session (or one session), for a group to follow along.
|
// Finish → Lesson handout: a printable page for each session (or one session), for a group to follow along.
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { printHandout, sessionsFor } from '../utils/handout.js';
|
import { HANDOUT_LAYOUTS, printHandout, sessionsFor } from '../utils/handout.js';
|
||||||
|
|
||||||
export default function HandoutExport({ project }) {
|
export default function HandoutExport({ project }) {
|
||||||
const sessions = sessionsFor(project);
|
const sessions = sessionsFor(project);
|
||||||
const [which, setWhich] = useState('all');
|
const [which, setWhich] = useState('all');
|
||||||
const [lines, setLines] = useState(true);
|
const [lines, setLines] = useState(true);
|
||||||
|
const [layout, setLayout] = useState('standard');
|
||||||
const [blocked, setBlocked] = useState(false);
|
const [blocked, setBlocked] = useState(false);
|
||||||
if (sessions.length === 0) return null;
|
if (sessions.length === 0) return null;
|
||||||
const go = () => setBlocked(!printHandout(project, which === 'all' ? null : [which], { answerLines: lines }));
|
const go = () => setBlocked(!printHandout(project, which === 'all' ? null : [which], { answerLines: lines, layout }));
|
||||||
return (
|
return (
|
||||||
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Lesson handout">
|
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Lesson handout">
|
||||||
<h2 className="text-sm font-medium">Lesson handout</h2>
|
<h2 className="text-sm font-medium">Lesson handout</h2>
|
||||||
@@ -20,6 +21,11 @@ export default function HandoutExport({ project }) {
|
|||||||
{sessions.map((s) => <option key={s.chunk.id} value={s.chunk.id}>{s.number}. {s.reference}</option>)}
|
{sessions.map((s) => <option key={s.chunk.id} value={s.chunk.id}>{s.number}. {s.reference}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="text-xs text-[var(--r-sub)]">Layout
|
||||||
|
<select value={layout} onChange={(e) => setLayout(e.target.value)} className="mt-1 block rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)]">
|
||||||
|
{HANDOUT_LAYOUTS.map((l) => <option key={l.id} value={l.id}>{l.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label className="flex items-center gap-2 pb-2 text-xs text-[var(--r-sub)]"><input type="checkbox" checked={lines} onChange={(e) => setLines(e.target.checked)} className="accent-[var(--r-accent)]" /> Lines to write on</label>
|
<label className="flex items-center gap-2 pb-2 text-xs text-[var(--r-sub)]"><input type="checkbox" checked={lines} onChange={(e) => setLines(e.target.checked)} className="accent-[var(--r-accent)]" /> Lines to write on</label>
|
||||||
<button type="button" onClick={go} className="rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5">Print / Save PDF</button>
|
<button type="button" onClick={go} className="rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5">Print / Save PDF</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// Finish → Import notes: bring notes from a Word or Markdown file into this study, matched to its chunks by passage.
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useApp } from '../context/AppContext.js';
|
||||||
|
import { useToast } from './Toasts.jsx';
|
||||||
|
import { applyImportedNotes, parseNotesText } from '../utils/importNotes.js';
|
||||||
|
|
||||||
|
export default function ImportNotes() {
|
||||||
|
const { project, updateProject } = useApp();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [file, setFile] = useState('');
|
||||||
|
const [sections, setSections] = useState(null); // null until a file is read
|
||||||
|
const [own, setOwn] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const choose = async (e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
e.target.value = '';
|
||||||
|
setError(''); setSections(null);
|
||||||
|
if (!f) return;
|
||||||
|
try {
|
||||||
|
let text;
|
||||||
|
if (/\.docx$/i.test(f.name)) {
|
||||||
|
const { default: mammoth } = await import('mammoth');
|
||||||
|
text = (await mammoth.extractRawText({ arrayBuffer: await f.arrayBuffer() })).value;
|
||||||
|
} else text = await f.text();
|
||||||
|
const found = parseNotesText(text);
|
||||||
|
setFile(f.name);
|
||||||
|
setSections(found);
|
||||||
|
if (found.length === 0) setError('No passage headings with Observe / Interpret / Apply notes were found in that file. Start each section with a heading like “John 3:16–18”.');
|
||||||
|
} catch { setError('That file couldn’t be read.'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const preview = sections ? applyImportedNotes(project, sections, { own }) : null;
|
||||||
|
const add = () => {
|
||||||
|
updateProject((current) => applyImportedNotes(current, sections, { own }).project);
|
||||||
|
toast(`Added notes to ${preview.matched.length} chunk${preview.matched.length === 1 ? '' : 's'}`);
|
||||||
|
setSections(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Import notes">
|
||||||
|
<h2 className="text-sm font-medium">Import notes</h2>
|
||||||
|
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">Bring in notes from a Word (.docx), Markdown or text file. Start each section with a passage heading (“John 3:16–18”), then label the notes “Observe:”, “Interpret:”, “Apply:”, “Background:” or “Questions:”. Notes you’ve already written are kept; imported text is added below them.</p>
|
||||||
|
<label className="inline-block cursor-pointer rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5">Choose a file
|
||||||
|
<input type="file" accept=".docx,.md,.markdown,.txt,text/markdown,text/plain" className="hidden" onChange={choose} />
|
||||||
|
</label>
|
||||||
|
{error && <p role="alert" className="mt-2 text-xs text-[var(--r-danger,#b42318)]">{error}</p>}
|
||||||
|
{preview && sections.length > 0 && (
|
||||||
|
<div className="mt-3 space-y-2 text-sm">
|
||||||
|
<p className="text-xs text-[var(--r-sub)]">{file}: {sections.length} section{sections.length === 1 ? '' : 's'} found.</p>
|
||||||
|
<ul className="space-y-1 text-xs">
|
||||||
|
{preview.matched.map((m) => <li key={m.heading}>✓ {m.heading}: {m.fields.join(', ')}</li>)}
|
||||||
|
{preview.unmatched.map((h) => <li key={h} className="text-[var(--r-sub)]">No chunk for {h}, skipped</li>)}
|
||||||
|
</ul>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-[var(--r-sub)]"><input type="checkbox" checked={own} onChange={(e) => setOwn(e.target.checked)} className="accent-[var(--r-accent)]" /> These notes are my own writing (untick if they came from AI or another source)</label>
|
||||||
|
<button type="button" onClick={add} disabled={preview.matched.length === 0} className="rounded-xl bg-[var(--r-text)] px-4 py-2 text-sm font-medium text-[var(--r-bg)] disabled:opacity-40">Add to this study</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,21 +11,27 @@ export const expiryText = (expiresAt, now = Date.now()) => {
|
|||||||
return `Expires ${new Date(expiresAt).toLocaleDateString()} (${days} day${days === 1 ? '' : 's'} left)`;
|
return `Expires ${new Date(expiresAt).toLocaleDateString()} (${days} day${days === 1 ? '' : 's'} left)`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function LinkRules({ expiresAt, hasPasscode, busy, onSave }) {
|
// "2026-10-18T09:00" in the person's own time zone <-> ms
|
||||||
|
const toLocalInput = (ms) => { if (!ms) return ''; const d = new Date(ms); const pad = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; };
|
||||||
|
|
||||||
|
// `showReveal` adds "Hide my notes until…" (study links only; an outline link never shows notes).
|
||||||
|
export default function LinkRules({ expiresAt, hasPasscode, busy, onSave, showReveal = false, revealAt = null }) {
|
||||||
const [days, setDays] = useState('keep'); // 'keep' | '' | '1' | '7' | '30'
|
const [days, setDays] = useState('keep'); // 'keep' | '' | '1' | '7' | '30'
|
||||||
const [passcode, setPasscode] = useState('');
|
const [passcode, setPasscode] = useState('');
|
||||||
const dirty = days !== 'keep' || passcode.trim() !== '';
|
const [reveal, setReveal] = useState(null); // null = leave as is; '' = show everything now; else a local date-time
|
||||||
|
const dirty = days !== 'keep' || passcode.trim() !== '' || reveal !== null;
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
const options = {};
|
const options = {};
|
||||||
if (days !== 'keep') options.expiresInDays = days === '' ? null : Number(days);
|
if (days !== 'keep') options.expiresInDays = days === '' ? null : Number(days);
|
||||||
if (passcode.trim()) options.passcode = passcode.trim();
|
if (passcode.trim()) options.passcode = passcode.trim();
|
||||||
if (await onSave(options)) { setDays('keep'); setPasscode(''); }
|
if (reveal !== null) options.revealAt = reveal === '' ? null : new Date(reveal).getTime();
|
||||||
|
if (await onSave(options)) { setDays('keep'); setPasscode(''); setReveal(null); }
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 rounded-xl border border-[var(--r-line)] p-3" role="group" aria-label="Link settings">
|
<div className="mt-3 rounded-xl border border-[var(--r-line)] p-3" role="group" aria-label="Link settings">
|
||||||
<p className="text-xs text-[var(--r-sub)]">{expiryText(expiresAt)} · {hasPasscode ? 'Needs a passcode' : 'No passcode'}</p>
|
<p className="text-xs text-[var(--r-sub)]">{expiryText(expiresAt)} · {hasPasscode ? 'Needs a passcode' : 'No passcode'}{showReveal && revealAt && revealAt > Date.now() ? ` · Your notes appear ${new Date(revealAt).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })}` : ''}</p>
|
||||||
<div className="mt-2 flex flex-wrap items-end gap-3">
|
<div className="mt-2 flex flex-wrap items-end gap-3">
|
||||||
<label className="text-xs text-[var(--r-sub)]">Stop working
|
<label className="text-xs text-[var(--r-sub)]">Stop working
|
||||||
<select value={days} onChange={(e) => setDays(e.target.value)} className={`${field} mt-1 block`}>
|
<select value={days} onChange={(e) => setDays(e.target.value)} className={`${field} mt-1 block`}>
|
||||||
@@ -36,10 +42,18 @@ export default function LinkRules({ expiresAt, hasPasscode, busy, onSave }) {
|
|||||||
<label className="text-xs text-[var(--r-sub)]">{hasPasscode ? 'New passcode' : 'Passcode (optional)'}
|
<label className="text-xs text-[var(--r-sub)]">{hasPasscode ? 'New passcode' : 'Passcode (optional)'}
|
||||||
<input type="text" value={passcode} onChange={(e) => setPasscode(e.target.value)} maxLength={100} autoComplete="off" placeholder={hasPasscode ? 'Type to change' : 'None'} className={`${field} mt-1 block w-44`} />
|
<input type="text" value={passcode} onChange={(e) => setPasscode(e.target.value)} maxLength={100} autoComplete="off" placeholder={hasPasscode ? 'Type to change' : 'None'} className={`${field} mt-1 block w-44`} />
|
||||||
</label>
|
</label>
|
||||||
|
{showReveal && (
|
||||||
|
<label className="text-xs text-[var(--r-sub)]">Hide my notes until
|
||||||
|
<input type="datetime-local" value={reveal ?? toLocalInput(revealAt > Date.now() ? revealAt : null)} onChange={(e) => setReveal(e.target.value)} className={`${field} mt-1 block`} />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{showReveal && (reveal ?? toLocalInput(revealAt > Date.now() ? revealAt : null)) && (
|
||||||
|
<button type="button" onClick={() => setReveal('')} className="text-xs text-[var(--r-sub)] hover:underline">Show everything now</button>
|
||||||
|
)}
|
||||||
<button type="button" onClick={save} disabled={!dirty || busy} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-sm hover:bg-black/5 disabled:opacity-50">Save settings</button>
|
<button type="button" onClick={save} disabled={!dirty || busy} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-sm hover:bg-black/5 disabled:opacity-50">Save settings</button>
|
||||||
{hasPasscode && <button type="button" onClick={() => onSave({ passcode: null })} disabled={busy} className="text-xs text-[var(--r-danger,#b42318)] hover:underline">Remove passcode</button>}
|
{hasPasscode && <button type="button" onClick={() => onSave({ passcode: null })} disabled={busy} className="text-xs text-[var(--r-danger,#b42318)] hover:underline">Remove passcode</button>}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-[11px] text-[var(--r-sub)]">Send the passcode separately from the link. Anyone with both can read it until it expires or you turn it off.</p>
|
<p className="mt-2 text-[11px] text-[var(--r-sub)]">Send the passcode separately from the link. Anyone with both can read it until it expires or you turn it off.{showReveal ? ' Until the time you set, the group sees only the passages and discussion questions; your notes, outline and scripts stay on the server.' : ''}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// On a study card: whether its Bible text works without a connection, and a button to fix that.
|
||||||
|
import { DownloadProgress } from './OfflineSave.jsx';
|
||||||
|
import { offlineStatus } from '../utils/offline.js';
|
||||||
|
|
||||||
|
export default function OfflineBadge({ project, offline }) {
|
||||||
|
if (!project) return null;
|
||||||
|
const status = offlineStatus(project, offline.saved);
|
||||||
|
const label = 'mt-1 text-xs';
|
||||||
|
if (offline.job) return <div className="mt-2"><DownloadProgress job={offline.job} onCancel={offline.cancel} /></div>;
|
||||||
|
if (status.state === 'ready') return <p className={`${label} text-[var(--r-accent)]`} title="Your notes and the Bible text for this study open without a connection">✓ Works offline</p>;
|
||||||
|
if (status.state === 'licensed') {
|
||||||
|
return <p className={`${label} text-[var(--r-sub)]`} title={`The ${status.translation} licence doesn’t allow saving its text for offline use`}>Notes work offline; {status.translation} text needs a connection</p>;
|
||||||
|
}
|
||||||
|
const next = status.missing[0];
|
||||||
|
return (
|
||||||
|
<p className={`${label} flex flex-wrap items-center gap-x-2 text-[var(--r-sub)]`}>
|
||||||
|
<span>Needs a connection for {status.missing.map((b) => b.name).join(', ')}</span>
|
||||||
|
<button type="button" onClick={() => offline.saveBook(status.translation, next.abbrev, next.name)} className="text-[var(--r-accent)] hover:underline">Save {next.name} for offline</button>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// Home card: the prayers you pinned, in the order you set, with a one-tap "I prayed". Hidden when nothing is pinned.
|
||||||
|
import { useApp } from '../context/AppContext.js';
|
||||||
|
import { STATUSES, pinned } from '../utils/prayers.js';
|
||||||
|
import { localDateString } from '../utils/readingPlans.js';
|
||||||
|
|
||||||
|
export default function PinnedPrayers() {
|
||||||
|
const { prayers, togglePrayed, openPrayer } = useApp();
|
||||||
|
const all = pinned(prayers);
|
||||||
|
const list = all.slice(0, 5);
|
||||||
|
const total = all.length;
|
||||||
|
if (!list.length) return null;
|
||||||
|
const today = localDateString();
|
||||||
|
return (
|
||||||
|
<section aria-label="Pinned prayers" className="rounded-xl border border-[var(--r-accent)] border-l-4 bg-[var(--r-bar)] px-4 py-3 text-sm shadow-sm">
|
||||||
|
<div className="mb-1 flex items-baseline justify-between gap-3">
|
||||||
|
<h2 className="flex items-center gap-1.5 font-semibold text-[var(--r-accent)]"><svg viewBox="0 0 24 24" className="h-4 w-4" fill="currentColor" aria-hidden="true"><path d="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" /></svg>Praying for <span className="text-xs font-normal text-[var(--r-sub)]">{total > list.length ? `${list.length} of ${total}` : total}</span></h2>
|
||||||
|
<button type="button" onClick={openPrayer} className="text-xs text-[var(--r-accent)] hover:underline">Open journal →</button>
|
||||||
|
</div>
|
||||||
|
<ul className="divide-y divide-[var(--r-line)]">
|
||||||
|
{list.map((p) => {
|
||||||
|
const done = p.prayedOn.includes(today);
|
||||||
|
const days = p.prayedOn.length;
|
||||||
|
return (
|
||||||
|
<li key={p.id} className="flex items-start gap-3 py-2.5 first:pt-1.5 last:pb-0.5">
|
||||||
|
<button type="button" onClick={() => togglePrayed(p.id)} aria-pressed={done} aria-label={`${done ? 'Prayed today' : 'I prayed'}: ${p.text.slice(0, 40)}`}
|
||||||
|
title={done ? 'Prayed today (tap to undo)' : 'Mark as prayed today'}
|
||||||
|
className={`mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-xs ${done ? 'border-[var(--r-accent)] bg-[var(--r-accent)] text-[var(--r-on)]' : 'border-[var(--r-line)] text-transparent hover:border-[var(--r-accent)] hover:text-[var(--r-accent)]'}`}>✓</button>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{(p.forWhom || p.status) && (
|
||||||
|
<p className="mb-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] font-medium uppercase tracking-wide text-[var(--r-sub)]">
|
||||||
|
{p.forWhom && <span>{p.forWhom}</span>}
|
||||||
|
{p.status && <span className="rounded-full border border-[var(--r-accent)] px-1.5 py-px text-[10px] normal-case tracking-normal text-[var(--r-accent)]">{STATUSES[p.status]}</span>}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="line-clamp-3 leading-snug">{p.text}</p>
|
||||||
|
{p.status === 'partial' && p.answerNote && <p className="mt-1 line-clamp-2 text-xs italic text-[var(--r-sub)]">{p.answerNote}</p>}
|
||||||
|
{days > 0 && <p className="mt-1 text-[11px] text-[var(--r-sub)]">Prayed {days} day{days === 1 ? '' : 's'}</p>}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { vi, test, expect } from 'vitest';
|
||||||
|
import { AppContext } from '../context/AppContext.js';
|
||||||
|
import PinnedPrayers from './PinnedPrayers.jsx';
|
||||||
|
import { markAnswered, newPrayer, setPinned } from '../utils/prayers.js';
|
||||||
|
|
||||||
|
const T0 = Date.UTC(2026, 9, 1);
|
||||||
|
const show = (prayers) => {
|
||||||
|
const ctx = { prayers, togglePrayed: vi.fn(), openPrayer: vi.fn() };
|
||||||
|
const view = render(<AppContext.Provider value={ctx}><PinnedPrayers /></AppContext.Provider>);
|
||||||
|
return { ctx, view };
|
||||||
|
};
|
||||||
|
|
||||||
|
test('shows nothing when no prayer is pinned', () => {
|
||||||
|
const { view } = show([newPrayer({ text: 'Unpinned' }, T0)]);
|
||||||
|
expect(view.container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lists open pinned prayers in order, lets you mark one prayed, and opens the journal', async () => {
|
||||||
|
const a = newPrayer({ text: 'For Sam', forWhom: 'Sam' }, T0);
|
||||||
|
const b = newPrayer({ text: 'Wisdom' }, T0 + 1);
|
||||||
|
const c = newPrayer({ text: 'Done already' }, T0 + 2);
|
||||||
|
let list = [a, b, c];
|
||||||
|
list = setPinned(setPinned(setPinned(list, b.id, true, T0 + 5), a.id, true, T0 + 6), c.id, true, T0 + 7);
|
||||||
|
list = list.map((p) => (p.id === c.id ? markAnswered(p, '', T0 + 9) : p));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { ctx } = show(list);
|
||||||
|
const rows = screen.getAllByRole('listitem');
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
expect(rows[0]).toHaveTextContent('Wisdom');
|
||||||
|
expect(rows[1]).toHaveTextContent('Sam');
|
||||||
|
await user.click(screen.getAllByRole('button', { name: /I prayed/ })[0]);
|
||||||
|
expect(ctx.togglePrayed).toHaveBeenCalledWith(b.id);
|
||||||
|
await user.click(screen.getByRole('button', { name: /Open journal/ }));
|
||||||
|
expect(ctx.openPrayer).toHaveBeenCalled();
|
||||||
|
});
|
||||||
@@ -1,8 +1,15 @@
|
|||||||
// Present mode: the script or the outline full-screen in large type, with a timer. The script scrolls by
|
// Present mode: the script or the outline full-screen in large type, with a timer. The script scrolls by
|
||||||
// itself at a speed you set (a teleprompter), and the outline moves one point at a time with the minutes you
|
// itself at a speed you set (a teleprompter), and the outline moves one point at a time with the minutes you
|
||||||
// planned beside the clock. The screen is kept awake while it is open. Esc, or Close, leaves.
|
// planned beside the clock. The screen is kept awake while it is open. Esc, or Close, leaves.
|
||||||
|
//
|
||||||
|
// With a projector or second screen: "Audience window" opens a second window to drag over. It shows only the point,
|
||||||
|
// its passage and quoted verse; your speaker notes, the script, the timer and the plan stay on this screen. This
|
||||||
|
// screen then becomes a presenter view (what the audience sees, your notes, the next point). On one screen, notes are
|
||||||
|
// hidden unless you turn them on, so a mirrored display never shows them by accident. B blacks out the audience screen.
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { formatClock, outlineSteps, paceText, pacing } from '../utils/present.js';
|
import { formatClock, outlineSteps, paceText, pacing } from '../utils/present.js';
|
||||||
|
import { audienceState, audienceUrl, openChannel } from '../utils/presentSync.js';
|
||||||
|
import StepFace from './StepFace.jsx';
|
||||||
|
|
||||||
const SIZES = [20, 24, 28, 34, 40, 48, 60, 72];
|
const SIZES = [20, 24, 28, 34, 40, 48, 60, 72];
|
||||||
const btn = 'rounded-lg border border-current/30 px-3 py-1.5 text-sm hover:bg-white/10 disabled:opacity-40';
|
const btn = 'rounded-lg border border-current/30 px-3 py-1.5 text-sm hover:bg-white/10 disabled:opacity-40';
|
||||||
@@ -16,10 +23,43 @@ export default function PresentMode({ title, subtitle = '', mode, script = '', t
|
|||||||
const [speed, setSpeed] = useState(30); // pixels per second
|
const [speed, setSpeed] = useState(30); // pixels per second
|
||||||
const [index, setIndex] = useState(0);
|
const [index, setIndex] = useState(0);
|
||||||
const scroller = useRef(null);
|
const scroller = useRef(null);
|
||||||
|
const [showNotes, setShowNotes] = useState(() => { try { return localStorage.getItem('present-notes-here') === 'on'; } catch { return false; } });
|
||||||
|
const [audienceOpen, setAudienceOpen] = useState(false); // an audience window is connected: this screen is the presenter's
|
||||||
|
const [blank, setBlank] = useState(false); // the audience screen is blacked out
|
||||||
|
const [audSize, setAudSize] = useState(() => { try { return Math.max(0, SIZES.indexOf(Number(localStorage.getItem('present-aud-size')) || 48)); } catch { return 5; } });
|
||||||
|
const [popupBlocked, setPopupBlocked] = useState(false);
|
||||||
|
const audienceWin = useRef(null);
|
||||||
|
const channel = useRef(null);
|
||||||
|
const latest = useRef(null);
|
||||||
const steps = mode === 'outline' ? outlineSteps(tree) : [];
|
const steps = mode === 'outline' ? outlineSteps(tree) : [];
|
||||||
const step = steps[index];
|
const step = steps[index];
|
||||||
const pace = mode === 'outline' ? pacing(steps, index, elapsed) : null;
|
const pace = mode === 'outline' ? pacing(steps, index, elapsed) : null;
|
||||||
|
|
||||||
|
// Two screens: keep the audience window in step with this one, answering a window that has just opened.
|
||||||
|
const audience = audienceState({ title, subtitle, mode, step, index, total: steps.length, dark, fontPx: SIZES[audSize], blank });
|
||||||
|
latest.current = audience;
|
||||||
|
useEffect(() => {
|
||||||
|
const ch = openChannel();
|
||||||
|
channel.current = ch;
|
||||||
|
if (!ch) return undefined;
|
||||||
|
ch.onmessage = (e) => { if (e.data?.type === 'hello') { setAudienceOpen(true); ch.postMessage(latest.current); } };
|
||||||
|
return () => { ch.postMessage({ type: 'end' }); ch.close(); channel.current = null; };
|
||||||
|
}, []);
|
||||||
|
useEffect(() => { channel.current?.postMessage(audience); }); // every change: the point, the colours, blank
|
||||||
|
useEffect(() => {
|
||||||
|
if (!audienceOpen) return undefined;
|
||||||
|
const t = window.setInterval(() => { if (audienceWin.current?.closed) { audienceWin.current = null; setAudienceOpen(false); setBlank(false); } }, 1000);
|
||||||
|
return () => window.clearInterval(t);
|
||||||
|
}, [audienceOpen]);
|
||||||
|
const openAudience = () => {
|
||||||
|
if (!channel.current) return;
|
||||||
|
const win = window.open(audienceUrl(), 'bible-study-audience', 'popup,width=1280,height=720');
|
||||||
|
setPopupBlocked(!win);
|
||||||
|
if (win) { audienceWin.current = win; setAudienceOpen(true); }
|
||||||
|
};
|
||||||
|
const toggleNotes = () => setShowNotes((on) => { const next = !on; try { localStorage.setItem('present-notes-here', next ? 'on' : 'off'); } catch { /* storage unavailable */ } return next; });
|
||||||
|
const audBigger = (d) => setAudSize((i) => { const next = Math.min(SIZES.length - 1, Math.max(0, i + d)); try { localStorage.setItem('present-aud-size', String(SIZES[next])); } catch { /* storage unavailable */ } return next; });
|
||||||
|
|
||||||
// the clock
|
// the clock
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!running) return undefined;
|
if (!running) return undefined;
|
||||||
@@ -68,13 +108,20 @@ export default function PresentMode({ title, subtitle = '', mode, script = '', t
|
|||||||
if (e.key === 'ArrowRight' || e.key === 'ArrowDown' || e.key === ' ' || e.key === 'PageDown') { e.preventDefault(); go(1); }
|
if (e.key === 'ArrowRight' || e.key === 'ArrowDown' || e.key === ' ' || e.key === 'PageDown') { e.preventDefault(); go(1); }
|
||||||
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp' || e.key === 'PageUp') { e.preventDefault(); go(-1); }
|
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp' || e.key === 'PageUp') { e.preventDefault(); go(-1); }
|
||||||
} else if (e.key === ' ') { e.preventDefault(); setScrolling((s) => !s); }
|
} else if (e.key === ' ') { e.preventDefault(); setScrolling((s) => !s); }
|
||||||
|
if ((e.key === 'b' || e.key === 'B') && audienceOpen) setBlank((b) => !b);
|
||||||
|
if ((e.key === 'n' || e.key === 'N') && !audienceOpen && mode === 'outline') toggleNotes();
|
||||||
if (e.key === '+' || e.key === '=') bigger(1);
|
if (e.key === '+' || e.key === '=') bigger(1);
|
||||||
if (e.key === '-') bigger(-1);
|
if (e.key === '-') bigger(-1);
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
return () => window.removeEventListener('keydown', onKey);
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
}, [mode, go, onClose]);
|
}, [mode, go, onClose, audienceOpen]);
|
||||||
|
|
||||||
|
const banner = audienceOpen && (
|
||||||
|
<p role="status" className={`shrink-0 px-4 py-1.5 text-center text-xs ${blank ? 'bg-red-500/25' : 'bg-emerald-500/15'}`}>
|
||||||
|
{blank ? 'The audience screen is blank. Press B to bring it back.' : mode === 'script' ? 'The audience window is connected. It shows only the title; your script stays on this screen.' : 'The audience window is connected. It shows the points and verses, never your notes.'}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
const colors = dark ? 'bg-[#111] text-[#f2efe8]' : 'bg-[#faf7f0] text-[#2b2620]';
|
const colors = dark ? 'bg-[#111] text-[#f2efe8]' : 'bg-[#faf7f0] text-[#2b2620]';
|
||||||
const fontSize = `${SIZES[size]}px`;
|
const fontSize = `${SIZES[size]}px`;
|
||||||
const total = steps.length ? steps[steps.length - 1].plannedEnd : 0;
|
const total = steps.length ? steps[steps.length - 1].plannedEnd : 0;
|
||||||
@@ -102,29 +149,73 @@ export default function PresentMode({ title, subtitle = '', mode, script = '', t
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{typeof BroadcastChannel !== 'undefined' ? (
|
||||||
|
<div className="flex items-center gap-2" role="group" aria-label="Second screen">
|
||||||
|
{audienceOpen ? (
|
||||||
|
<>
|
||||||
|
<button type="button" onClick={() => setBlank((b) => !b)} aria-pressed={blank} className={btn} title="Black out the audience screen (B)">{blank ? 'Show audience' : 'Blank audience'}</button>
|
||||||
|
{mode === 'outline' && (
|
||||||
|
<span className="flex items-center gap-1" role="group" aria-label="Audience text size">
|
||||||
|
<button type="button" onClick={() => audBigger(-1)} disabled={audSize === 0} aria-label="Smaller audience text" className={btn}>Screen A−</button>
|
||||||
|
<button type="button" onClick={() => audBigger(1)} disabled={audSize === SIZES.length - 1} aria-label="Larger audience text" className={btn}>A+</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button type="button" onClick={openAudience} className={btn} title="Opens a second window for a projector or other screen. It shows the points, never your notes.">Audience window</button>
|
||||||
|
{mode === 'outline' && <button type="button" onClick={toggleNotes} aria-pressed={showNotes} className={btn} title="Speaker notes stay hidden on this screen unless you turn them on (N)">{showNotes ? 'Hide notes' : 'Show notes'}</button>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<button type="button" onClick={onClose} className={`${btn} font-medium`}>Close</button>
|
<button type="button" onClick={onClose} className={`${btn} font-medium`}>Close</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mode === 'script' ? (
|
{mode === 'script' ? (
|
||||||
|
<>
|
||||||
|
{banner}
|
||||||
<div ref={scroller} className="min-h-0 flex-1 overflow-y-auto px-[6vw] py-[8dvh]" style={{ scrollBehavior: 'auto' }}>
|
<div ref={scroller} className="min-h-0 flex-1 overflow-y-auto px-[6vw] py-[8dvh]" style={{ scrollBehavior: 'auto' }}>
|
||||||
{script.trim()
|
{script.trim()
|
||||||
? <div className="mx-auto max-w-[46em] whitespace-pre-wrap font-serif" style={{ fontSize, lineHeight: 1.5 }}>{script}<div style={{ height: '50dvh' }} aria-hidden="true" /></div>
|
? <div className="mx-auto max-w-[46em] whitespace-pre-wrap font-serif" style={{ fontSize, lineHeight: 1.5 }}>{script}<div style={{ height: '50dvh' }} aria-hidden="true" /></div>
|
||||||
: <p className="mt-20 text-center text-lg opacity-70">There’s no script to present yet. Write one on the Script page.</p>}
|
: <p className="mt-20 text-center text-lg opacity-70">There’s no script to present yet. Write one on the Script page.</p>}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex min-h-0 flex-1 flex-col">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
|
{popupBlocked && <p role="alert" className="shrink-0 bg-amber-500/20 px-4 py-1.5 text-center text-xs">Your browser blocked the audience window. Allow pop-ups for this site, then try again.</p>}
|
||||||
|
{banner}
|
||||||
|
{audienceOpen && step ? (
|
||||||
|
// Presenter view: what the audience sees, your private notes, and what comes next
|
||||||
|
<div className="grid min-h-0 flex-1 gap-0 md:grid-cols-[3fr_2fr]">
|
||||||
|
<div className="min-h-0 overflow-y-auto border-current/15 px-[3vw] py-[4dvh] md:border-r">
|
||||||
|
<p className="mb-3 font-sans text-[11px] uppercase tracking-[0.16em] opacity-50">The audience sees</p>
|
||||||
|
<StepFace step={step} index={index} total={steps.length} fontSize={`${SIZES[Math.max(0, size - 3)]}px`} minutes={step.minutes} />
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 overflow-y-auto px-[3vw] py-[4dvh]">
|
||||||
|
<p className="mb-3 font-sans text-[11px] uppercase tracking-[0.16em] text-amber-500">Only you see this</p>
|
||||||
|
{step.note.trim()
|
||||||
|
? <p className="whitespace-pre-wrap font-serif" style={{ fontSize, lineHeight: 1.45 }}>{step.note}</p>
|
||||||
|
: <p className="font-sans text-sm opacity-50">No speaker notes for this point. Add them under the point’s “⋯” details on the Outline page.</p>}
|
||||||
|
{steps[index + 1] && (
|
||||||
|
<div className="mt-8 border-t border-current/15 pt-4">
|
||||||
|
<p className="font-sans text-[11px] uppercase tracking-[0.16em] opacity-50">Next</p>
|
||||||
|
<p className="mt-1 font-serif text-xl"><span className="mr-2 opacity-60">{steps[index + 1].label}</span>{steps[index + 1].text || '(untitled)'}</p>
|
||||||
|
{steps[index + 1].minutes > 0 && <p className="font-sans text-xs opacity-60">{steps[index + 1].minutes} min</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-[6vw] py-[6dvh]">
|
<div className="min-h-0 flex-1 overflow-y-auto px-[6vw] py-[6dvh]">
|
||||||
{step ? (
|
{step ? (
|
||||||
<div className="mx-auto max-w-[46em] font-serif" style={{ fontSize, lineHeight: 1.4 }}>
|
<>
|
||||||
<p className="font-sans text-[0.4em] uppercase tracking-[0.16em] opacity-60">{step.kind ? `${step.kind} · ` : ''}Point {index + 1} of {steps.length}{step.minutes ? ` · ${step.minutes} min` : ''}</p>
|
<StepFace step={step} index={index} total={steps.length} fontSize={fontSize} minutes={step.minutes} note={showNotes ? step.note : ''} />
|
||||||
<h1 className="mt-2 font-semibold"><span className="mr-3 opacity-60">{step.label}</span>{step.text || '(untitled)'}</h1>
|
{showNotes && step.note && <p className="mx-auto mt-2 max-w-[46em] font-sans text-[11px] uppercase tracking-[0.16em] text-amber-500">Speaker notes are showing on this screen (N to hide)</p>}
|
||||||
{step.ref && <p className="mt-2 text-[0.6em] opacity-70">{step.ref}</p>}
|
</>
|
||||||
{step.scripture?.text && <blockquote className="mt-5 border-l-4 border-current/30 pl-5 text-[0.8em] italic opacity-90">“{step.scripture.text}” <span className="not-italic opacity-60">— {step.scripture.ref}</span></blockquote>}
|
|
||||||
{step.note && <p className="mt-5 whitespace-pre-wrap text-[0.7em] opacity-85">{step.note}</p>}
|
|
||||||
{step.attachments.length > 0 && <p className="mt-5 text-[0.55em] opacity-60">{step.attachments.map((a) => `${a.type === 'word' ? 'Word' : 'See'}: ${a.label}`).join(' · ')}</p>}
|
|
||||||
</div>
|
|
||||||
) : <p className="mt-20 text-center text-lg opacity-70">There’s no outline to present yet. Build one on the Outline page.</p>}
|
) : <p className="mt-20 text-center text-lg opacity-70">There’s no outline to present yet. Build one on the Outline page.</p>}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-current/15 px-4 py-3">
|
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-current/15 px-4 py-3">
|
||||||
<button type="button" onClick={() => go(-1)} disabled={index === 0} className={btn}>← Back</button>
|
<button type="button" onClick={() => go(-1)} disabled={index === 0} className={btn}>← Back</button>
|
||||||
<span className="text-xs opacity-60">{total ? `${total} min planned` : 'Set minutes on points to see your pace'} · arrows or space to move</span>
|
<span className="text-xs opacity-60">{total ? `${total} min planned` : 'Set minutes on points to see your pace'} · arrows or space to move</span>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { render, screen, act } from '@testing-library/react';
|
import { render, screen, act } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { vi, test, expect, afterEach } from 'vitest';
|
import { vi, test, expect, afterEach, beforeEach } from 'vitest';
|
||||||
import PresentMode from './PresentMode.jsx';
|
import PresentMode from './PresentMode.jsx';
|
||||||
|
|
||||||
afterEach(() => vi.useRealTimers());
|
beforeEach(() => localStorage.clear());
|
||||||
|
afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); });
|
||||||
const n = (id, text, minutes, extra = {}) => ({ id, text, ref: '', chunkId: null, minutes, note: '', kind: '', scripture: null, attachments: [], children: [], ...extra });
|
const n = (id, text, minutes, extra = {}) => ({ id, text, ref: '', chunkId: null, minutes, note: '', kind: '', scripture: null, attachments: [], children: [], ...extra });
|
||||||
const tree = [n('a', 'Welcome', 2, { note: 'Say hello' }), n('b', 'The gospel', 10, { ref: 'Rom 1:16' })];
|
const tree = [n('a', 'Welcome', 2, { note: 'Say hello' }), n('b', 'The gospel', 10, { ref: 'Rom 1:16' })];
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ test('the outline is stepped through a point at a time, with the keyboard or the
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<PresentMode mode="outline" title="Romans" tree={tree} onClose={() => {}} />);
|
render(<PresentMode mode="outline" title="Romans" tree={tree} onClose={() => {}} />);
|
||||||
expect(screen.getByText('Welcome')).toBeInTheDocument();
|
expect(screen.getByText('Welcome')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Say hello')).toBeInTheDocument();
|
expect(screen.queryByText('Say hello')).not.toBeInTheDocument(); // speaker notes are private by default
|
||||||
expect(screen.getByText(/Point 1 of 2/)).toBeInTheDocument();
|
expect(screen.getByText(/Point 1 of 2/)).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: /back/i })).toBeDisabled();
|
expect(screen.getByRole('button', { name: /back/i })).toBeDisabled();
|
||||||
await user.keyboard('{ArrowRight}');
|
await user.keyboard('{ArrowRight}');
|
||||||
@@ -58,3 +59,77 @@ test('Escape and Close both leave, and an empty script says what to do', async (
|
|||||||
await user.click(screen.getByRole('button', { name: 'Close' }));
|
await user.click(screen.getByRole('button', { name: 'Close' }));
|
||||||
expect(onClose).toHaveBeenCalledTimes(2);
|
expect(onClose).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A stand-in for the browser's BroadcastChannel that records what the presenter window sends
|
||||||
|
class FakeChannel {
|
||||||
|
static instances = [];
|
||||||
|
constructor() { this.sent = []; FakeChannel.instances.push(this); }
|
||||||
|
postMessage(m) { this.sent.push(m); }
|
||||||
|
close() {}
|
||||||
|
}
|
||||||
|
const withChannel = () => { FakeChannel.instances = []; vi.stubGlobal('BroadcastChannel', FakeChannel); };
|
||||||
|
const last = () => FakeChannel.instances[0].sent.filter((m) => m.type === 'state').at(-1);
|
||||||
|
|
||||||
|
test('on one screen the speaker notes stay hidden until you turn them on', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<PresentMode mode="outline" title="Romans" tree={tree} onClose={() => {}} />);
|
||||||
|
expect(screen.queryByText('Say hello')).not.toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Show notes' }));
|
||||||
|
expect(screen.getByText('Say hello')).toBeInTheDocument();
|
||||||
|
expect(localStorage.getItem('present-notes-here')).toBe('on');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Hide notes' }));
|
||||||
|
expect(screen.queryByText('Say hello')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('with an audience window the notes appear only on the presenter screen, never in what is sent', async () => {
|
||||||
|
withChannel();
|
||||||
|
vi.stubGlobal('open', vi.fn(() => ({ closed: false })));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<PresentMode mode="outline" title="Romans" tree={tree} onClose={() => {}} />);
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Audience window' }));
|
||||||
|
expect(window.open).toHaveBeenCalledWith(expect.stringContaining('?audience=1'), 'bible-study-audience', expect.any(String));
|
||||||
|
expect(screen.getByText('Only you see this')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Say hello')).toBeInTheDocument(); // the presenter's own screen
|
||||||
|
expect(screen.getByText(/never your notes/i)).toBeInTheDocument();
|
||||||
|
expect(last()).toMatchObject({ mode: 'outline', index: 0, total: 2, blank: false });
|
||||||
|
expect(last().step.text).toBe('Welcome');
|
||||||
|
const everything = JSON.stringify(FakeChannel.instances[0].sent);
|
||||||
|
expect(everything).not.toContain('Say hello');
|
||||||
|
expect(screen.getByText('Next')).toBeInTheDocument(); // the preview of the next point
|
||||||
|
await user.keyboard('{ArrowRight}');
|
||||||
|
expect(last().step.text).toBe('The gospel');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a window that opens later is brought up to date, and B blacks the audience screen out', async () => {
|
||||||
|
withChannel();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<PresentMode mode="outline" title="Romans" tree={tree} onClose={() => {}} />);
|
||||||
|
const channel = FakeChannel.instances[0];
|
||||||
|
act(() => channel.onmessage({ data: { type: 'hello' } })); // someone opened the audience address by hand
|
||||||
|
expect(await screen.findByText('Only you see this')).toBeInTheDocument();
|
||||||
|
expect(channel.sent.at(-1).step.text).toBe('Welcome');
|
||||||
|
await user.keyboard('b');
|
||||||
|
expect(last().blank).toBe(true);
|
||||||
|
expect(screen.getByText(/audience screen is blank/i)).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Show audience' }));
|
||||||
|
expect(last().blank).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('in script mode the audience is only ever sent the title', async () => {
|
||||||
|
withChannel();
|
||||||
|
vi.stubGlobal('open', vi.fn(() => ({ closed: false })));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<PresentMode mode="script" title="Romans 8" script="My private script text" onClose={() => {}} />);
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Audience window' }));
|
||||||
|
expect(last()).toMatchObject({ mode: 'script', title: 'Romans 8', step: null });
|
||||||
|
expect(JSON.stringify(FakeChannel.instances[0].sent)).not.toContain('private script');
|
||||||
|
expect(screen.getByText(/only the title/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the presentation ending tells the audience window', () => {
|
||||||
|
withChannel();
|
||||||
|
const { unmount } = render(<PresentMode mode="outline" title="Romans" tree={tree} onClose={() => {}} />);
|
||||||
|
const channel = FakeChannel.instances[0];
|
||||||
|
unmount();
|
||||||
|
expect(channel.sent.at(-1)).toEqual({ type: 'end' });
|
||||||
|
});
|
||||||
@@ -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="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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Under a note: each Scripture reference written in it (Rom 8:28, Psalm 23…) as a chip. Click one to read the verse
|
||||||
|
// (BSB) right there, or open it in the Bible reader. The text itself is never changed.
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useApp } from '../context/AppContext.js';
|
||||||
|
import { fetchChapter } from '../utils/bibleText.js';
|
||||||
|
import { labelFor, uniqueReferences } from '../utils/scriptureRefs.js';
|
||||||
|
|
||||||
|
const MAX_VERSES = 12;
|
||||||
|
const CHAPTER_PREVIEW = 6;
|
||||||
|
|
||||||
|
function Preview({ r }) {
|
||||||
|
const { jumpToReaderVerse, openBibleReader } = useApp();
|
||||||
|
const [state, setState] = useState({ loading: true });
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
setState({ loading: true });
|
||||||
|
fetchChapter('BSB', r.book, r.chapter)
|
||||||
|
.then(({ verses }) => {
|
||||||
|
const from = r.verse ?? 1;
|
||||||
|
const to = r.verse ? (r.endVerse ?? r.verse) : from + CHAPTER_PREVIEW - 1;
|
||||||
|
const picked = verses.filter((v) => v.number >= from && v.number <= to).slice(0, MAX_VERSES);
|
||||||
|
if (live) setState({ verses: picked, more: !r.verse && verses.length > picked.length });
|
||||||
|
})
|
||||||
|
.catch((err) => { if (live) setState({ error: err.message || 'Could not load the verse.' }); });
|
||||||
|
return () => { live = false; };
|
||||||
|
}, [r.book, r.chapter, r.verse, r.endVerse]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div role="region" aria-label={`${labelFor(r)} preview`} className="mt-1.5 rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-3 text-[13px]">
|
||||||
|
{state.loading && <p className="text-xs text-[var(--r-sub)]">Loading…</p>}
|
||||||
|
{state.error && <p className="text-xs text-[var(--r-sub)]">{state.error} You can still open it in the Bible.</p>}
|
||||||
|
{state.verses && (state.verses.length === 0
|
||||||
|
? <p className="text-xs text-[var(--r-sub)]">That verse isn’t in the BSB.</p>
|
||||||
|
: <div className="space-y-1 font-serif leading-relaxed">{state.verses.map((v) => <p key={v.number}><sup className="mr-1 font-sans text-[var(--r-accent)]">{v.number}</sup>{v.text}</p>)}{state.more && <p className="text-xs text-[var(--r-sub)]">…</p>}</div>)}
|
||||||
|
<div className="mt-2 flex items-center gap-3 font-sans text-xs">
|
||||||
|
<button type="button" onClick={() => { jumpToReaderVerse(r.book, r.chapter, r.verse ?? 1); openBibleReader(); }} className="text-[var(--r-accent)] hover:underline">Read in the Bible</button>
|
||||||
|
<span className="text-[var(--r-sub)]">Berean Standard Bible</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ScriptureRefs({ text }) {
|
||||||
|
const refs = useMemo(() => uniqueReferences(text), [text]);
|
||||||
|
const [open, setOpen] = useState(null);
|
||||||
|
if (refs.length === 0) return null;
|
||||||
|
const openRef = refs.find((r) => labelFor(r) === open);
|
||||||
|
return (
|
||||||
|
<div className="mt-1.5" aria-label="References in this note">
|
||||||
|
<p className="flex flex-wrap items-center gap-1.5 text-xs">
|
||||||
|
<span className="text-[var(--r-sub)]">Verses mentioned:</span>
|
||||||
|
{refs.map((r) => {
|
||||||
|
const label = labelFor(r);
|
||||||
|
return (
|
||||||
|
<button key={label} type="button" aria-pressed={open === label} onClick={() => setOpen(open === label ? null : label)}
|
||||||
|
className={`rounded-full border px-2 py-0.5 ${open === label ? 'border-[var(--r-accent)] text-[var(--r-text)]' : 'border-[var(--r-line)] text-[var(--r-accent)] hover:bg-black/5'}`}>{label}</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
{openRef && <Preview r={openRef} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { render, screen, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { vi, test, expect, beforeEach } from 'vitest';
|
||||||
|
import { AppContext } from '../context/AppContext.js';
|
||||||
|
import ScriptureRefs from './ScriptureRefs.jsx';
|
||||||
|
|
||||||
|
const jump = vi.fn();
|
||||||
|
const open = vi.fn();
|
||||||
|
beforeEach(() => {
|
||||||
|
jump.mockClear(); open.mockClear();
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async () => ({
|
||||||
|
ok: true, status: 200,
|
||||||
|
json: async () => ({ book: { numberOfChapters: 16 }, verses: [28, 29, 30].map((n) => ({ number: n, text: `Verse ${n} text.` })) }),
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
const setup = (text) => render(<AppContext.Provider value={{ jumpToReaderVerse: jump, openBibleReader: open }}><ScriptureRefs text={text} /></AppContext.Provider>);
|
||||||
|
|
||||||
|
test('references in a note become chips, and a note with none shows nothing', () => {
|
||||||
|
const { container } = setup('Nothing to see here.');
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking a reference shows the verse, and "Read in the Bible" opens the reader there', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
setup('God works all things together, Rom 8:28-29. See also Romans 8:28.');
|
||||||
|
expect(screen.getAllByRole('button', { name: /Romans 8/ })).toHaveLength(2); // 8:28–29 and 8:28 are different references
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Romans 8:28–29' }));
|
||||||
|
const preview = await screen.findByRole('region', { name: /Romans 8:28–29 preview/ });
|
||||||
|
expect(await within(preview).findByText('Verse 28 text.')).toBeInTheDocument();
|
||||||
|
expect(within(preview).getByText('Verse 29 text.')).toBeInTheDocument();
|
||||||
|
expect(within(preview).queryByText('Verse 30 text.')).not.toBeInTheDocument();
|
||||||
|
await user.click(within(preview).getByRole('button', { name: 'Read in the Bible' }));
|
||||||
|
expect(jump).toHaveBeenCalledWith('ROM', 8, 28);
|
||||||
|
expect(open).toHaveBeenCalled();
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Romans 8:28–29' }));
|
||||||
|
expect(screen.queryByRole('region')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failure to load the verse still leaves the link to the Bible', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
setup('Psalm 23:1');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Psalms 23:1' }));
|
||||||
|
const preview = await screen.findByRole('region');
|
||||||
|
expect(await within(preview).findByText(/You can still open it in the Bible/)).toBeInTheDocument();
|
||||||
|
expect(within(preview).getByRole('button', { name: 'Read in the Bible' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// Finish → Series progress: every session of the study with where it stands (studied, ready to teach, taught) and
|
||||||
|
// what is still open on it, so a long study doesn't lose track of itself.
|
||||||
|
import { seriesProgress, STAGES } from '../utils/seriesProgress.js';
|
||||||
|
|
||||||
|
const PILL = ['border border-[var(--r-line)] text-[var(--r-sub)]', 'bg-[var(--r-bar)] border border-[var(--r-line)]', 'border border-[var(--r-accent)] text-[var(--r-accent)]', 'bg-[var(--r-accent)] text-[var(--r-on)]'];
|
||||||
|
|
||||||
|
export default function SeriesProgress({ project, onOpen }) {
|
||||||
|
const { rows, counts, taught, total, next } = seriesProgress(project);
|
||||||
|
if (total < 2) return null; // a series needs more than one session
|
||||||
|
return (
|
||||||
|
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Series progress">
|
||||||
|
<h2 className="text-sm font-medium">Series progress</h2>
|
||||||
|
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">
|
||||||
|
{taught} of {total} session{total === 1 ? '' : 's'} taught
|
||||||
|
{next ? `. Next up: session ${next.number}, ${next.reference}${next.title ? ` (${next.title})` : ''}.` : '. The series is finished.'}
|
||||||
|
{' '}Worked out from what you’ve written: notes make a session “Studied”, an outline, script or questions make it “Ready to teach”, and session notes make it “Taught”.
|
||||||
|
</p>
|
||||||
|
<div className="mb-3 flex h-2 overflow-hidden rounded-full bg-[var(--r-line)]" role="img" aria-label={STAGES.map((s, i) => `${counts[i]} ${s.toLowerCase()}`).join(', ')}>
|
||||||
|
{counts.map((n, i) => n > 0 && <span key={STAGES[i]} style={{ width: `${(n / total) * 100}%`, opacity: [0, 0.35, 0.65, 1][i] }} className={i === 0 ? '' : 'bg-[var(--r-accent)]'} />)}
|
||||||
|
</div>
|
||||||
|
<ul className="divide-y divide-[var(--r-line)]">
|
||||||
|
{rows.map((r) => (
|
||||||
|
<li key={r.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 py-2 text-sm">
|
||||||
|
<span className="w-6 shrink-0 text-xs text-[var(--r-sub)]">{r.number}</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
{onOpen
|
||||||
|
? <button type="button" onClick={() => onOpen(r.id)} className="text-left hover:underline">{r.reference}{r.title ? ` · ${r.title}` : ''}</button>
|
||||||
|
: <span>{r.reference}{r.title ? ` · ${r.title}` : ''}</span>}
|
||||||
|
</span>
|
||||||
|
{(r.openFollowUps > 0 || r.openComments > 0) && (
|
||||||
|
<span className="text-xs text-[var(--r-sub)]">
|
||||||
|
{[r.openFollowUps > 0 && `${r.openFollowUps} follow-up${r.openFollowUps === 1 ? '' : 's'}`, r.openComments > 0 && `${r.openComments} comment${r.openComments === 1 ? '' : 's'}`].filter(Boolean).join(' · ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={`shrink-0 rounded-full px-2.5 py-0.5 text-xs ${PILL[r.stage]}`}>{STAGES[r.stage]}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// Export → Schedule: the study's sessions (one per chunk) as a calendar file, starting on a date you pick.
|
// Finish → Schedule: the study's sessions (one per chunk) as a calendar file, starting on a date you pick.
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { formatChunkReference } from '../utils/bibleUtils.jsx';
|
import { formatChunkReference } from '../utils/bibleUtils.jsx';
|
||||||
import { buildIcs, downloadIcs, sessionDates } from '../utils/icsFile.js';
|
import { buildIcs, downloadIcs, sessionDates } from '../utils/icsFile.js';
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ const BY_PAGE = {
|
|||||||
],
|
],
|
||||||
study: [
|
study: [
|
||||||
['← →', 'Previous / next chunk'],
|
['← →', 'Previous / next chunk'],
|
||||||
|
['1 2 3', 'Write in Observe / Interpret / Apply'],
|
||||||
|
['Esc', 'Leave a note box (then keys work again)'],
|
||||||
|
['C W X P R', 'Commentary / Word study / Cross-refs / People & places / Reading list'],
|
||||||
|
['V', 'Compare passages or translations'],
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// Finish → Slides: a printable slide deck for a session or the whole study (Save as PDF in the print dialog).
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { sessionsFor } from '../utils/handout.js';
|
||||||
|
import { printSlides } from '../utils/slides.js';
|
||||||
|
|
||||||
|
export default function SlidesExport({ project }) {
|
||||||
|
const sessions = sessionsFor(project);
|
||||||
|
const [which, setWhich] = useState('all');
|
||||||
|
const [dark, setDark] = useState(false);
|
||||||
|
const [blocked, setBlocked] = useState(false);
|
||||||
|
if (sessions.length === 0) return null;
|
||||||
|
const go = () => setBlocked(!printSlides(project, which === 'all' ? null : [which], { dark }));
|
||||||
|
return (
|
||||||
|
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Slides">
|
||||||
|
<h2 className="text-sm font-medium">Slides</h2>
|
||||||
|
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">A 16:9 slide for each session’s passage, each outline point (with the verse it quotes) and the discussion questions. Choose “Save as PDF” in the print dialog, then open it in any slide viewer or project it from the PDF. It isn’t a PowerPoint file.</p>
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<label className="text-xs text-[var(--r-sub)]">Sessions
|
||||||
|
<select value={which} onChange={(e) => setWhich(e.target.value)} className="mt-1 block rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)]">
|
||||||
|
<option value="all">All {sessions.length}</option>
|
||||||
|
{sessions.map((s) => <option key={s.chunk.id} value={s.chunk.id}>{s.number}. {s.reference}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 pb-2 text-xs text-[var(--r-sub)]"><input type="checkbox" checked={dark} onChange={(e) => setDark(e.target.checked)} className="accent-[var(--r-accent)]" /> Dark slides</label>
|
||||||
|
<button type="button" onClick={go} className="rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5">Print / Save PDF</button>
|
||||||
|
</div>
|
||||||
|
{blocked && <p role="alert" className="mt-2 text-xs text-[var(--r-danger,#b42318)]">Allow pop-ups for this site to print the slides.</p>}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// One outline point as it appears on screen: the point, its passage and quoted verse, and what it carries.
|
||||||
|
// Used for the whole screen in present mode, for the presenter's preview, and on the audience window. `note` and
|
||||||
|
// `minutes` are only passed where the speaker is the one looking.
|
||||||
|
export default function StepFace({ step, index, total, fontSize, minutes = null, note = '' }) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-[46em] font-serif" style={{ fontSize, lineHeight: 1.4 }}>
|
||||||
|
<p className="font-sans text-[0.4em] uppercase tracking-[0.16em] opacity-60">{step.kind ? `${step.kind} · ` : ''}Point {index + 1} of {total}{minutes ? ` · ${minutes} min` : ''}</p>
|
||||||
|
<h1 className="mt-2 font-semibold"><span className="mr-3 opacity-60">{step.label}</span>{step.text || '(untitled)'}</h1>
|
||||||
|
{step.ref && <p className="mt-2 text-[0.6em] opacity-70">{step.ref}</p>}
|
||||||
|
{step.scripture?.text && <blockquote className="mt-5 border-l-4 border-current/30 pl-5 text-[0.8em] italic opacity-90">“{step.scripture.text}” <span className="not-italic opacity-60">— {step.scripture.ref}</span></blockquote>}
|
||||||
|
{note && <p className="mt-5 whitespace-pre-wrap text-[0.7em] opacity-85">{note}</p>}
|
||||||
|
{step.attachments?.length > 0 && <p className="mt-5 text-[0.55em] opacity-60">{step.attachments.map((a) => `${a.type === 'word' ? 'Word' : 'See'}: ${a.label}`).join(' · ')}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
|||||||
import { useApp } from '../context/AppContext.js';
|
import { useApp } from '../context/AppContext.js';
|
||||||
import { useIqAvailable } from '../hooks/useIq.js';
|
import { useIqAvailable } from '../hooks/useIq.js';
|
||||||
import IqCommentary from './iq/IqCommentary.jsx';
|
import IqCommentary from './iq/IqCommentary.jsx';
|
||||||
|
import Family from './FamilyTree.jsx';
|
||||||
import {
|
import {
|
||||||
COMMENTARIES, loadCommentary, loadTheographic, loadPerson, pickCommentaryEntry, useAsync,
|
COMMENTARIES, loadCommentary, loadTheographic, loadPerson, pickCommentaryEntry, useAsync,
|
||||||
} from '../hooks/useVerseStudy.js';
|
} from '../hooks/useVerseStudy.js';
|
||||||
@@ -71,6 +72,7 @@ function PersonDetails({ id }) {
|
|||||||
<div className="mt-1 space-y-1 text-xs leading-relaxed text-[var(--r-sub,#64748b)]">
|
<div className="mt-1 space-y-1 text-xs leading-relaxed text-[var(--r-sub,#64748b)]">
|
||||||
{(data.description ?? []).map((d, i) => <p key={i}>{d}</p>)}
|
{(data.description ?? []).map((d, i) => <p key={i}>{d}</p>)}
|
||||||
{!data.description?.length && <p>No description available.</p>}
|
{!data.description?.length && <p>No description available.</p>}
|
||||||
|
<Family person={data} renderPerson={(pid) => <PersonDetails key={pid} id={pid} />} />
|
||||||
{data.minYear != null && (
|
{data.minYear != null && (
|
||||||
<p className="text-[var(--r-sub,#64748b)]">
|
<p className="text-[var(--r-sub,#64748b)]">
|
||||||
Lived approx. {era(data.minYear)}{data.maxYear != null && data.maxYear !== data.minYear ? ` – ${era(data.maxYear)}` : ''}
|
Lived approx. {era(data.minYear)}{data.maxYear != null && data.maxYear !== data.minYear ? ` – ${era(data.maxYear)}` : ''}
|
||||||
|
|||||||
@@ -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)]'}`}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Export → Earlier versions: the copies the server kept of this study as you worked. Look at what is different
|
// Finish → Earlier versions: the copies the server kept of this study as you worked. Look at what is different
|
||||||
// from now, and put one back. The version you replace is kept, so putting one back can be undone too.
|
// from now, and put one back. The version you replace is kept, so putting one back can be undone too.
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { getStudyVersion, listStudyVersions } from '../syncService.js';
|
import { getStudyVersion, listStudyVersions } from '../syncService.js';
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// 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 [gaps, setGaps] = 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, gaps: gaps.trim() });
|
||||||
|
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>
|
||||||
|
<label className="mt-3 block text-xs text-[var(--r-sub)]">What did you miss or get wrong? <span className="opacity-70">(one per line; collected in Insights)</span>
|
||||||
|
<textarea aria-label="What slipped" value={gaps} onChange={(e) => setGaps(e.target.value)} rows={2}
|
||||||
|
className="mt-1 w-full resize-y rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none" />
|
||||||
|
</label>
|
||||||
|
<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,21 +46,22 @@ 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 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<p className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Verse of the day</p>
|
<p className="whitespace-nowrap text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Verse of the day</p>
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<select aria-label="Verse of the day background" value={bgChoice} onChange={(e) => pickBackground(e.target.value)}
|
<select aria-label="Verse of the day background" value={bgChoice} onChange={(e) => pickBackground(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">
|
||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// A Bible dictionary lookup (Easton, Hastings, Hitchcock, Schaff, Smith: all public domain, from IQ Bible).
|
||||||
|
// Type a word, pick a dictionary; the entry named for the word comes first and the entries that only mention it
|
||||||
|
// are folded below. Scripture references in an entry open in the Reader.
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useApp } from '../../context/AppContext.js';
|
||||||
|
import { useIq } from '../../hooks/useIq.js';
|
||||||
|
import { pickEntries, preview, refTarget } from '../../utils/dictionary.js';
|
||||||
|
|
||||||
|
const field = 'rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
|
||||||
|
|
||||||
|
function Entry({ entry, credit, open: startOpen }) {
|
||||||
|
const { jumpToReaderVerse, openBibleReader } = useApp();
|
||||||
|
const [open, setOpen] = useState(startOpen);
|
||||||
|
const p = preview(entry.definition);
|
||||||
|
const refs = [...new Set(entry.scripture_refs ?? [])].map(refTarget).filter(Boolean).slice(0, 14);
|
||||||
|
return (
|
||||||
|
<article className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-3">
|
||||||
|
<h4 className="font-medium">{entry.term}</h4>
|
||||||
|
<p className="mt-1 whitespace-pre-line text-[13px] leading-relaxed">{open || !p.cut ? entry.definition : p.text}</p>
|
||||||
|
{p.cut && <button type="button" onClick={() => setOpen((o) => !o)} className="mt-1 text-xs text-[var(--r-accent)] hover:underline">{open ? 'Show less' : 'Read the whole entry'}</button>}
|
||||||
|
{refs.length > 0 && (
|
||||||
|
<p className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{refs.map((r) => (
|
||||||
|
<button key={r.label} type="button" onClick={() => { jumpToReaderVerse(r.book, r.chapter, r.verse); openBibleReader(); }} title="Read it in the Bible"
|
||||||
|
className="rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[11px] text-[var(--r-accent)] hover:bg-black/5">{r.label}</button>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-1.5 text-[10px] text-[var(--r-sub)]">{credit}</p>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function IqDictionary() {
|
||||||
|
const [dictId, setDictId] = useState(() => { try { return localStorage.getItem('dictionary') || 'easton'; } catch { return 'easton'; } });
|
||||||
|
const [typed, setTyped] = useState('');
|
||||||
|
const [asked, setAsked] = useState('');
|
||||||
|
const list = useIq('dictionaries');
|
||||||
|
const dictionaries = list.data?.data ?? [];
|
||||||
|
const current = dictionaries.find((d) => d.id === dictId) ?? dictionaries[0];
|
||||||
|
const result = useIq(asked && current ? `dictionaries/${current.id}` : null, { q: asked });
|
||||||
|
const { exact, others } = pickEntries(result.data?.data, asked);
|
||||||
|
const credit = current ? `${current.name}, ${current.author}, ${current.year}. ${current.license}.` : '';
|
||||||
|
const choose = (id) => { setDictId(id); try { localStorage.setItem('dictionary', id); } catch { /* storage unavailable */ } };
|
||||||
|
const ask = () => setAsked(typed.trim().slice(0, 100));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details className="mb-3 rounded-xl border border-[var(--r-line)] p-3" aria-label="Bible dictionary">
|
||||||
|
<summary className="cursor-pointer text-sm font-medium">Bible dictionary</summary>
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input type="text" aria-label="Look up a word" value={typed} onChange={(e) => setTyped(e.target.value)} placeholder="A name, place or word, e.g. cubit"
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); ask(); } }} className={`${field} min-w-0 flex-1`} />
|
||||||
|
<select aria-label="Dictionary" value={current?.id ?? dictId} onChange={(e) => choose(e.target.value)} className={field}>
|
||||||
|
{dictionaries.length === 0 && <option value={dictId}>{dictId}</option>}
|
||||||
|
{dictionaries.map((d) => <option key={d.id} value={d.id}>{d.name.replace(/ Bible Dictionary| Dictionary of the Bible| Bible Names Dictionary/, '')} ({d.entry_count})</option>)}
|
||||||
|
</select>
|
||||||
|
<button type="button" onClick={ask} disabled={!typed.trim()} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50">Look up</button>
|
||||||
|
</div>
|
||||||
|
{result.status === 'loading' && <p className="text-xs text-[var(--r-sub)]">Looking up…</p>}
|
||||||
|
{result.status === 'error' && <p role="alert" className="text-xs text-[var(--r-danger,#b42318)]">{result.error || 'The dictionary could not be reached.'}</p>}
|
||||||
|
{asked && (result.status === 'ready' || result.status === 'empty') && !exact && others.length === 0 && (
|
||||||
|
<p className="text-xs text-[var(--r-sub)]">Nothing for “{asked}” in this dictionary. Try another dictionary or the singular of the word.</p>
|
||||||
|
)}
|
||||||
|
{exact && <Entry entry={exact} credit={credit} open={false} />}
|
||||||
|
{others.length > 0 && (
|
||||||
|
<details className="text-xs">
|
||||||
|
<summary className="cursor-pointer text-[var(--r-sub)]">{exact ? 'Other entries that mention' : 'Entries that mention'} “{asked}” ({others.length})</summary>
|
||||||
|
<div className="mt-2 space-y-2">{others.slice(0, 8).map((e) => <Entry key={e.slug ?? e.term} entry={e} credit={credit} open={false} />)}</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { render, screen, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { vi, test, expect, beforeEach } from 'vitest';
|
||||||
|
import { AppContext } from '../../context/AppContext.js';
|
||||||
|
import IqDictionary from './IqDictionary.jsx';
|
||||||
|
|
||||||
|
const LIST = { count: 2, data: [
|
||||||
|
{ id: 'easton', name: "Easton's Bible Dictionary", author: 'Matthew George Easton', year: '1897', license: 'Public domain', entry_count: 3962 },
|
||||||
|
{ id: 'smith', name: "Smith's Bible Dictionary", author: 'William Smith', year: '1863', license: 'Public domain', entry_count: 4488 },
|
||||||
|
] };
|
||||||
|
const ANSWER = { count: 2, data: [
|
||||||
|
{ term: 'Alabaster', slug: 'alabaster', definition: 'A stone box. It mentions a talent.', scripture_refs: ['Matthew 26:7'] },
|
||||||
|
{ term: 'Talent', slug: 'talent', definition: 'The largest Hebrew weight, about 75 pounds.', scripture_refs: ['Exodus 38:25', 'Matthew 25:15'] },
|
||||||
|
] };
|
||||||
|
|
||||||
|
const jump = vi.fn();
|
||||||
|
const open = vi.fn();
|
||||||
|
const asked = [];
|
||||||
|
beforeEach(() => {
|
||||||
|
asked.length = 0; jump.mockClear(); open.mockClear(); localStorage.clear();
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async (url) => {
|
||||||
|
asked.push(String(url));
|
||||||
|
const body = String(url).includes('/api/iq/dictionaries/') ? ANSWER : LIST;
|
||||||
|
return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) };
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('look up a word: the entry named for it comes first, its Scripture references open in the Reader', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<AppContext.Provider value={{ jumpToReaderVerse: jump, openBibleReader: open }}><IqDictionary /></AppContext.Provider>);
|
||||||
|
await user.click(screen.getByText('Bible dictionary'));
|
||||||
|
await user.type(await screen.findByRole('textbox', { name: 'Look up a word' }), 'talent{Enter}');
|
||||||
|
const entry = (await screen.findByRole('heading', { name: 'Talent' })).closest('article');
|
||||||
|
expect(within(entry).getByText(/about 75 pounds/)).toBeInTheDocument();
|
||||||
|
expect(asked.some((u) => /dictionaries\/easton\?q=talent/.test(u))).toBe(true);
|
||||||
|
await user.click(within(entry).getByRole('button', { name: 'Matthew 25:15' }));
|
||||||
|
expect(jump).toHaveBeenCalledWith('MAT', 25, 15);
|
||||||
|
expect(open).toHaveBeenCalled();
|
||||||
|
expect(within(entry).getByText(/Easton's Bible Dictionary, Matthew George Easton, 1897\. Public domain\./)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Entries that mention|Other entries that mention/)).toBeInTheDocument(); // Alabaster only mentions it
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the chosen dictionary is remembered and used for the next lookup', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<AppContext.Provider value={{ jumpToReaderVerse: jump, openBibleReader: open }}><IqDictionary /></AppContext.Provider>);
|
||||||
|
await user.click(screen.getByText('Bible dictionary'));
|
||||||
|
await user.selectOptions(await screen.findByRole('combobox', { name: 'Dictionary' }), 'smith');
|
||||||
|
expect(localStorage.getItem('dictionary')).toBe('smith');
|
||||||
|
await user.type(screen.getByRole('textbox', { name: 'Look up a word' }), 'cubit{Enter}');
|
||||||
|
await screen.findByRole('heading', { name: 'Talent' });
|
||||||
|
expect(asked.some((u) => /dictionaries\/smith\?q=cubit/.test(u))).toBe(true);
|
||||||
|
});
|
||||||
@@ -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,15 +1,16 @@
|
|||||||
// 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';
|
||||||
|
|
||||||
export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks, bookmarkCount, onHighlights, highlightCount, onCompare, compareActive, onSettings, onDoneDrawing }) {
|
export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks, bookmarkCount, onHighlights, highlightCount, onCompare, compareActive, onSettings, onDoneDrawing }) {
|
||||||
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="fixed-bar 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 />
|
||||||
@@ -43,7 +44,7 @@ export function BottomBar({
|
|||||||
}) {
|
}) {
|
||||||
const active = 'text-[var(--r-accent)]';
|
const active = 'text-[var(--r-accent)]';
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-x-0 bottom-0 z-30 border-t border-[var(--r-line)] bg-[var(--r-bar)] pb-[env(safe-area-inset-bottom)] font-sans">
|
<div className="fixed-bar fixed inset-x-0 bottom-0 z-30 border-t border-[var(--r-line)] bg-[var(--r-bar)] pb-[env(safe-area-inset-bottom)] font-sans">
|
||||||
<div className="mx-auto flex h-14 max-w-4xl items-center gap-0.5 px-2">
|
<div className="mx-auto flex h-14 max-w-4xl items-center gap-0.5 px-2">
|
||||||
<button type="button" onClick={onPrev} disabled={prevDisabled} className={iconBtn} aria-label="Previous chapter" data-tip="Previous chapter (←)"><ChevronLeft /></button>
|
<button type="button" onClick={onPrev} disabled={prevDisabled} className={iconBtn} aria-label="Previous chapter" data-tip="Previous chapter (←)"><ChevronLeft /></button>
|
||||||
<button type="button" onClick={onAudio} className={`${iconBtn} ${audioActive ? active : ''}`} aria-label="Listen" data-tip="Listen to this chapter"><HeadphonesIcon /></button>
|
<button type="button" onClick={onAudio} className={`${iconBtn} ${audioActive ? active : ''}`} aria-label="Listen" data-tip="Listen to this chapter"><HeadphonesIcon /></button>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ function OriginalTab({ words }) {
|
|||||||
{w.p && <div className="mt-0.5 text-[10px] leading-tight text-[var(--r-sub)]">{w.p}</div>}
|
{w.p && <div className="mt-0.5 text-[10px] leading-tight text-[var(--r-sub)]">{w.p}</div>}
|
||||||
{w.s && <div className="mt-0.5 text-[10px] text-[var(--r-sub)]">{w.s}</div>}
|
{w.s && <div className="mt-0.5 text-[10px] text-[var(--r-sub)]">{w.s}</div>}
|
||||||
{canSpeak && (
|
{canSpeak && (
|
||||||
<button type="button" onClick={() => speakOriginalWord(w.o, w.s)}
|
<button type="button" onClick={() => speakOriginalWord(w.o, w.s, w.t)}
|
||||||
className="mt-1.5 rounded-lg bg-[var(--r-bar)] px-2 py-0.5 text-[11px] hover:bg-black/5"
|
className="mt-1.5 rounded-lg bg-[var(--r-bar)] px-2 py-0.5 text-[11px] hover:bg-black/5"
|
||||||
title={`Pronounce in ${isHebrew ? 'Hebrew' : 'Greek'}`}>Speak</button>
|
title={`Pronounce in ${isHebrew ? 'Hebrew' : 'Greek'}`}>Speak</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useApp } from '../../context/AppContext.js';
|
|||||||
import { useToast } from '../Toasts.jsx';
|
import { useToast } from '../Toasts.jsx';
|
||||||
import { DownloadProgress } from '../OfflineSave.jsx';
|
import { DownloadProgress } from '../OfflineSave.jsx';
|
||||||
import { useOfflineSave } from '../../hooks/useOfflineSave.js';
|
import { useOfflineSave } from '../../hooks/useOfflineSave.js';
|
||||||
import { backupFileName, buildBackup, restoreProjects, validateBackup } from '../../utils/backup.js';
|
import { backupFileName, buildBackup, markBackedUp, restoreProjects, validateBackup } from '../../utils/backup.js';
|
||||||
import { saveRemoteProject } from '../../syncService.js';
|
import { saveRemoteProject } from '../../syncService.js';
|
||||||
|
|
||||||
const btn = 'rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5 disabled:opacity-50';
|
const btn = 'rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5 disabled:opacity-50';
|
||||||
@@ -38,6 +38,7 @@ export default function DataSection() {
|
|||||||
link.click();
|
link.click();
|
||||||
link.remove();
|
link.remove();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
|
markBackedUp();
|
||||||
toast(`Backup downloaded: ${plural(backup.projects.length, 'study', 'studies')} and your reading marks`);
|
toast(`Backup downloaded: ${plural(backup.projects.length, 'study', 'studies')} and your reading marks`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -80,7 +81,7 @@ export default function DataSection() {
|
|||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<button type="button" onClick={download} className={btn}>Download backup</button>
|
<button type="button" onClick={download} className={btn}>Download backup</button>
|
||||||
{authUser && (
|
{authUser && (
|
||||||
<a href="/api/account/export" download className={`${btn} inline-block`} title="Everything saved to your account on the server, including studies from other devices">Download from my account</a>
|
<a href="/api/account/export" download onClick={() => markBackedUp()} className={`${btn} inline-block`} title="Everything saved to your account on the server, including studies from other devices">Download from my account</a>
|
||||||
)}
|
)}
|
||||||
<button type="button" onClick={() => fileRef.current?.click()} className={btn}>Restore from a backup…</button>
|
<button type="button" onClick={() => fileRef.current?.click()} className={btn}>Restore from a backup…</button>
|
||||||
<input ref={fileRef} type="file" accept="application/json,.json" onChange={choose} className="hidden" aria-label="Backup file" />
|
<input ref={fileRef} type="file" accept="application/json,.json" onChange={choose} className="hidden" aria-label="Backup file" />
|
||||||
@@ -105,7 +106,7 @@ export default function DataSection() {
|
|||||||
<p className="mb-3 mt-1 text-sm text-[var(--r-sub)]">Chapters you read are kept automatically. To read anywhere, save a whole Bible while you’re connected (about 4 MB). The ESV, NLT, CSB and NASB can’t be saved: their licences don’t allow it.</p>
|
<p className="mb-3 mt-1 text-sm text-[var(--r-sub)]">Chapters you read are kept automatically. To read anywhere, save a whole Bible while you’re connected (about 4 MB). The ESV, NLT, CSB and NASB can’t be saved: their licences don’t allow it.</p>
|
||||||
{offline.job ? <DownloadProgress job={offline.job} onCancel={offline.cancel} /> : (
|
{offline.job ? <DownloadProgress job={offline.job} onCancel={offline.cancel} /> : (
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
{['BSB', 'NET'].map((t) => (
|
{['BSB', 'NET', 'KJV', 'ASV', 'LSV'].map((t) => (
|
||||||
<button key={t} type="button" onClick={() => offline.saveBible(t)} className={btn}>
|
<button key={t} type="button" onClick={() => offline.saveBible(t)} className={btn}>
|
||||||
{(offline.saved[t] ?? []).length >= 66 ? `✓ The whole ${t} is saved` : `Save the whole ${t}`}
|
{(offline.saved[t] ?? []).length >= 66 ? `✓ The whole ${t} is saved` : `Save the whole ${t}`}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { vi, test, expect, beforeEach } from 'vitest';
|
||||||
|
import { AppContext } from '../../context/AppContext.js';
|
||||||
|
import { ToastProvider } from '../Toasts.jsx';
|
||||||
|
import { lastBackupAt } from '../../utils/backup.js';
|
||||||
|
import DataSection from './DataSection.jsx';
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
authUser: { email: 'a@b.test' }, readerBookmarks: {}, readerBookmarkNotes: {}, readerTextHighlights: [], readerColorLabels: {}, readerInkByPage: {},
|
||||||
|
readingPlansState: { progress: [], customPlans: [], activity: [] }, importReaderMarks: vi.fn(), importReadingPlans: vi.fn(), refreshProjectIndex: vi.fn(), prayers: [], importPrayers: vi.fn(),
|
||||||
|
};
|
||||||
|
const setup = () => {
|
||||||
|
render(<AppContext.Provider value={value}><ToastProvider><DataSection /></ToastProvider></AppContext.Provider>);
|
||||||
|
return userEvent.setup();
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
URL.createObjectURL = vi.fn(() => 'blob:x');
|
||||||
|
URL.revokeObjectURL = vi.fn();
|
||||||
|
document.addEventListener('click', (e) => { if (e.target.closest?.('a[download]')) e.preventDefault(); }); // no real navigation in the test
|
||||||
|
});
|
||||||
|
|
||||||
|
test('downloading the backup file is remembered, so Home stops asking', async () => {
|
||||||
|
const user = setup();
|
||||||
|
expect(lastBackupAt()).toBeNull();
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Download backup' }));
|
||||||
|
expect(lastBackupAt()).toBeGreaterThan(Date.now() - 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('downloading from the account is remembered too', async () => {
|
||||||
|
const user = setup();
|
||||||
|
expect(lastBackupAt()).toBeNull();
|
||||||
|
await user.click(screen.getByRole('link', { name: 'Download from my account' }));
|
||||||
|
expect(lastBackupAt()).toBeGreaterThan(Date.now() - 5000);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// Comments for the people teaching this study together, shown in the notes' "More" area. Open ones can be marked done.
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { addComment, removeComment, toggleComment } from '../../utils/chunkComments.js';
|
||||||
|
|
||||||
|
const when = (at) => new Date(at).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
|
||||||
|
|
||||||
|
export default function ChunkComments({ chunk, updateChunk, author }) {
|
||||||
|
const comments = chunk.comments ?? [];
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
const set = (next) => updateChunk(chunk.id, { comments: next });
|
||||||
|
const post = () => {
|
||||||
|
const next = addComment(comments, { by: author, text });
|
||||||
|
if (next !== comments) { set(next); setText(''); }
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-[var(--r-sub)]">Comments <span className="opacity-70">(notes between the people teaching this study; tick one off when it’s dealt with)</span></p>
|
||||||
|
{comments.length > 0 && (
|
||||||
|
<ul className="mt-1.5 space-y-1.5" aria-label="Comments">
|
||||||
|
{comments.map((c) => (
|
||||||
|
<li key={c.id} className="flex items-start gap-2 rounded-xl border border-[var(--r-line)] px-2.5 py-2 text-sm">
|
||||||
|
<input type="checkbox" aria-label={`Done: ${c.text}`} checked={!!c.done} onChange={() => set(toggleComment(comments, c.id))} className="mt-1 accent-[var(--r-accent)]" />
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className={`block whitespace-pre-wrap ${c.done ? 'text-[var(--r-sub)] line-through' : ''}`}>{c.text}</span>
|
||||||
|
<span className="text-[11px] text-[var(--r-sub)]">{c.by} · {when(c.at)}</span>
|
||||||
|
</span>
|
||||||
|
<button type="button" aria-label={`Delete comment: ${c.text}`} onClick={() => set(removeComment(comments, c.id))} className="text-xs text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]">×</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<div className="mt-1.5 flex gap-2">
|
||||||
|
<input aria-label="Add a comment" value={text} onChange={(e) => setText(e.target.value)} maxLength={1000} placeholder="Write a comment, then Enter"
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); post(); } }}
|
||||||
|
className="min-w-0 flex-1 rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none" />
|
||||||
|
<button type="button" onClick={post} disabled={!text.trim()} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50">Post</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
// Compare two passages side by side — your notes and the verses — for parallels (the Gospels), repeated themes,
|
||||||
|
// or the same chapter studied twice. Pick any chunk from any of your studies to set beside the one you're on.
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useApp } from '../../context/AppContext.js';
|
||||||
|
import { loadProjectById } from '../../utils/projectStorage.js';
|
||||||
|
import { formatChunkReference, getChunkVerseEntries } from '../../utils/bibleUtils.jsx';
|
||||||
|
import { READER_TRANSLATIONS, fetchChapter, translationName } from '../../utils/bibleText.js';
|
||||||
|
import { alignVerses, differencesFor, markRanges, translationSpans } from '../../utils/compareTranslations.js';
|
||||||
|
import { ownShare, pastedWords } from '../../utils/ownWork.js';
|
||||||
|
import { usePasteCheck } from './PastePrompt.jsx';
|
||||||
|
|
||||||
|
const FIELDS = [['observation', 'Observe'], ['interpretation', 'Interpret'], ['application', 'Apply']];
|
||||||
|
|
||||||
|
// Every chunk in your studies, as a pick-list entry that remembers where it sits.
|
||||||
|
function choices(projectIndex, currentProject) {
|
||||||
|
const projects = projectIndex.map((e) => (e.id === currentProject?.id ? currentProject : loadProjectById(e.id))).filter(Boolean);
|
||||||
|
return projects.flatMap((project) => project.chapters.flatMap((chapter, chapterIndex) => chapter.chunks.map((chunk) => ({
|
||||||
|
key: `${project.id}:${chunk.id}`, project, chapterIndex, chunk,
|
||||||
|
label: `${formatChunkReference(project, chapterIndex, chunk, '–')} · ${project.title}`,
|
||||||
|
}))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function Column({ entry }) {
|
||||||
|
if (!entry) return <p className="p-4 text-sm text-[var(--r-sub)]">Pick a passage to compare.</p>;
|
||||||
|
const verses = getChunkVerseEntries(entry.project, entry.chapterIndex, entry.chunk);
|
||||||
|
return (
|
||||||
|
<div className="min-w-0 space-y-3 p-4">
|
||||||
|
<h3 className="font-medium">{entry.label}</h3>
|
||||||
|
<div className="max-h-48 space-y-1 overflow-y-auto rounded-xl bg-[var(--r-bar)] p-3 font-serif text-[13px] leading-relaxed">
|
||||||
|
{verses.map((v) => <p key={v.number}><sup className="mr-1 text-[var(--r-accent)]">{v.number}</sup>{v.text}</p>)}
|
||||||
|
</div>
|
||||||
|
{FIELDS.map(([f, label]) => (
|
||||||
|
<div key={f}>
|
||||||
|
<p className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">{label}</p>
|
||||||
|
<p className="whitespace-pre-line font-serif text-[14px] leading-relaxed">{entry.chunk[f]?.trim() || <span className="text-[var(--r-sub)]">Nothing written.</span>}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const OIA = [
|
||||||
|
['observation', 'Observe', 'What does the text say?'],
|
||||||
|
['interpretation', 'Interpret', 'What does it mean?'],
|
||||||
|
['application', 'Apply', 'What do I do with it?'],
|
||||||
|
];
|
||||||
|
|
||||||
|
// The chunk's own Observe / Interpret / Apply notes, editable right here while the translations are side by side.
|
||||||
|
// They are the same notes as on the Study page, and long pastes are tagged the same way.
|
||||||
|
function OiaBoxes({ chunk, updateChunk }) {
|
||||||
|
const share = ownShare(chunk);
|
||||||
|
const pasteCheck = usePasteCheck(chunk, updateChunk);
|
||||||
|
return (
|
||||||
|
<section aria-label="Your notes" className="border-t border-[var(--r-line)] px-4 pb-6 pt-4">
|
||||||
|
<p className="mb-3 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Your notes{share !== null ? ` · ${share}% your own words` : ''}</p>
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
{OIA.map(([field, label, hint]) => {
|
||||||
|
const pasted = pastedWords(chunk, field);
|
||||||
|
return (
|
||||||
|
<label key={field} className="flex flex-col text-xs text-[var(--r-sub)]">
|
||||||
|
<span className="mb-1 flex items-baseline justify-between"><span><span className="text-sm font-medium text-[var(--r-text)]">{label}</span> {hint}</span>{pasted > 0 && <span className="rounded-full bg-[var(--r-hl)] px-2 py-0.5 text-[var(--r-text)]">{pasted} pasted</span>}</span>
|
||||||
|
<textarea aria-label={label} value={chunk[field] ?? ''} rows={9}
|
||||||
|
onPaste={pasteCheck.onPaste(field)}
|
||||||
|
onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })}
|
||||||
|
className="min-h-[10rem] w-full resize-y rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2.5 font-serif text-[15px] leading-7 text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" />
|
||||||
|
{pasteCheck.prompt(field)}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One chunk's verses in several translations, a row per verse. Free translations load straight away; licensed ones
|
||||||
|
// come through the server and may be unavailable (no key set), which shows as a note instead of text.
|
||||||
|
function TranslationsView({ project, chunkEntry, updateChunk }) {
|
||||||
|
const [highlight, setHighlight] = useState(() => { try { return localStorage.getItem('compare-highlight') !== 'off'; } catch { return true; } });
|
||||||
|
const setHl = (on) => { setHighlight(on); try { localStorage.setItem('compare-highlight', on ? 'on' : 'off'); } catch { /* storage unavailable */ } };
|
||||||
|
const spans = useMemo(() => (chunkEntry ? translationSpans(project, chunkEntry.chapterIndex, chunkEntry.chunk) : []), [project, chunkEntry]);
|
||||||
|
const spanKey = spans.map((s) => `${s.book}${s.chapter}:${s.from}-${s.to}`).join(',');
|
||||||
|
const [picked, setPicked] = useState(() => {
|
||||||
|
try { const saved = JSON.parse(localStorage.getItem('compare-translations')); if (Array.isArray(saved) && saved.length) return saved; } catch { /* storage unavailable */ }
|
||||||
|
return [project?.translation ?? 'BSB', 'NET'];
|
||||||
|
});
|
||||||
|
const [loaded, setLoaded] = useState({}); // id -> { status: 'loading'|'ok'|'error', chapters, copyright, error }
|
||||||
|
const toggle = (id) => setPicked((cur) => {
|
||||||
|
const next = cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id];
|
||||||
|
try { localStorage.setItem('compare-translations', JSON.stringify(next)); } catch { /* storage unavailable */ }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
// The sheet is modal, so the passage can't change while it's open; results are only dropped after it closes.
|
||||||
|
const alive = useRef(true);
|
||||||
|
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
|
||||||
|
useEffect(() => {
|
||||||
|
picked.filter((id) => !loaded[id]).forEach((id) => {
|
||||||
|
setLoaded((cur) => ({ ...cur, [id]: { status: 'loading' } }));
|
||||||
|
Promise.all(spans.map((span) => fetchChapter(id, span.bookAbbrev, Number(span.chapter))
|
||||||
|
.then((res) => [`${span.book}|${span.chapter}`, res])))
|
||||||
|
.then((results) => alive.current && setLoaded((cur) => ({
|
||||||
|
...cur,
|
||||||
|
[id]: { status: 'ok', chapters: Object.fromEntries(results.map(([k, r]) => [k, r.verses])), copyright: results.map(([, r]) => r.copyright).find(Boolean) ?? '' },
|
||||||
|
})))
|
||||||
|
.catch((err) => { if (alive.current) setLoaded((cur) => ({ ...cur, [id]: { status: 'error', error: err.message || 'Unable to load.' } })); });
|
||||||
|
});
|
||||||
|
}, [picked, spanKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const shown = picked.filter((id) => loaded[id]?.status === 'ok');
|
||||||
|
const rows = alignVerses(spans, Object.fromEntries(shown.map((id) => [id, loaded[id].chapters])));
|
||||||
|
const cols = { gridTemplateColumns: `repeat(${Math.max(1, shown.length)}, minmax(0, 1fr))` };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 p-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2" role="group" aria-label="Translations to compare">
|
||||||
|
{READER_TRANSLATIONS.map((t) => {
|
||||||
|
const on = picked.includes(t.id);
|
||||||
|
return (
|
||||||
|
<button key={t.id} type="button" aria-pressed={on} onClick={() => toggle(t.id)} title={t.name}
|
||||||
|
className={`rounded-full border px-3 py-1 text-xs ${on ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>{t.id}</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{shown.length > 1 && (
|
||||||
|
<label className="flex items-center gap-2 text-xs text-[var(--r-sub)]"><input type="checkbox" checked={highlight} onChange={(e) => setHl(e.target.checked)} className="accent-[var(--r-accent)]" /> Highlight the words that differ from {shown[0]}</label>
|
||||||
|
)}
|
||||||
|
{picked.map((id) => loaded[id]?.status === 'error' && (
|
||||||
|
<p key={id} role="status" className="text-xs text-[var(--r-sub)]">{id} isn’t available right now: {loaded[id].error}</p>
|
||||||
|
))}
|
||||||
|
{picked.some((id) => loaded[id]?.status === 'loading') && <p className="text-xs text-[var(--r-sub)]">Loading…</p>}
|
||||||
|
{shown.length === 0 && !picked.some((id) => !loaded[id] || loaded[id].status === 'loading') && <p className="text-sm text-[var(--r-sub)]">Pick two or more translations above.</p>}
|
||||||
|
{shown.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="hidden gap-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)] md:grid" style={cols}>
|
||||||
|
{shown.map((id) => <span key={id}>{translationName(id)}</span>)}
|
||||||
|
</div>
|
||||||
|
{rows.map((row) => {
|
||||||
|
const diffs = highlight && shown.length > 1 ? differencesFor(row.texts, shown) : {};
|
||||||
|
const note = chunkEntry.chunk.verseNotes?.[row.noteKey] ?? '';
|
||||||
|
return (
|
||||||
|
<div key={row.key} className="border-t border-[var(--r-line)] pt-3">
|
||||||
|
<div className="grid gap-x-4 gap-y-1 md:[grid-template-columns:var(--cols)]" style={{ '--cols': cols.gridTemplateColumns }}>
|
||||||
|
{shown.map((id) => (
|
||||||
|
<p key={id} className="font-serif text-[14px] leading-relaxed">
|
||||||
|
<sup className="mr-1 font-sans text-[var(--r-accent)]">{row.label}</sup>
|
||||||
|
<span className="mr-1 font-sans text-[10px] uppercase tracking-wide text-[var(--r-sub)] md:hidden">{id}</span>
|
||||||
|
{row.texts[id] == null ? <span className="text-[var(--r-sub)]">—</span>
|
||||||
|
: markRanges(row.texts[id], diffs[id]).map((part, i) => (part.mark ? <mark key={i} className="rounded bg-[var(--r-hl)] px-0.5 text-inherit">{part.text}</mark> : <span key={i}>{part.text}</span>))}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input aria-label={`Note on verse ${row.label}`} value={note} placeholder={`Note on verse ${row.label}`}
|
||||||
|
onChange={(e) => updateChunk(chunkEntry.chunk.id, { verseNotes: { ...(chunkEntry.chunk.verseNotes ?? {}), [row.noteKey]: e.target.value } })}
|
||||||
|
className="mt-1.5 w-full rounded-lg border border-transparent bg-transparent px-2 py-1 text-xs text-[var(--r-text)] placeholder:text-[var(--r-sub)] placeholder:opacity-60 hover:border-[var(--r-line)] focus:border-[var(--r-line)] focus:bg-[var(--r-bg)] focus:outline-none" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="space-y-1 border-t border-[var(--r-line)] pt-3 text-[11px] leading-snug text-[var(--r-sub)]">
|
||||||
|
{shown.map((id) => {
|
||||||
|
const t = READER_TRANSLATIONS.find((x) => x.id === id);
|
||||||
|
return t?.notice || loaded[id].copyright ? <p key={id}>{loaded[id].copyright || t.notice}</p> : null;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CompareSheet({ currentChunkId, onClose }) {
|
||||||
|
const { projectIndex, project, updateChunk } = useApp();
|
||||||
|
// Other studies are read from storage once per list change; the open study comes straight from state so typing stays quick.
|
||||||
|
const others = useMemo(() => choices(projectIndex.filter((e) => e.id !== project?.id), null), [projectIndex, project?.id]);
|
||||||
|
const here = useMemo(() => (project?.chapters ?? []).flatMap((chapter, chapterIndex) => chapter.chunks.map((chunk) => ({
|
||||||
|
key: `${project.id}:${chunk.id}`, project, chapterIndex, chunk, label: `${formatChunkReference(project, chapterIndex, chunk, '–')} · ${project.title}`,
|
||||||
|
}))), [project]);
|
||||||
|
const mine = here.find((c) => c.chunk.id === currentChunkId);
|
||||||
|
const all = useMemo(() => [...here, ...others], [here, others]);
|
||||||
|
const [mode, setMode] = useState('passage'); // 'passage' | 'translations'
|
||||||
|
const [otherKey, setOtherKey] = useState('');
|
||||||
|
const other = all.find((c) => c.key === otherKey);
|
||||||
|
const tab = (id) => `rounded-full border px-3 py-1 text-xs ${mode === id ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`;
|
||||||
|
return (
|
||||||
|
<div role="dialog" aria-modal="true" aria-label="Compare passages" className="fixed inset-0 z-[70] flex flex-col bg-[var(--r-bg)] text-[var(--r-text)]">
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-2 border-b border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-2.5">
|
||||||
|
<h2 className="mr-auto text-sm font-medium sm:mr-0">Compare</h2>
|
||||||
|
<div role="tablist" aria-label="What to compare" className="flex gap-1.5">
|
||||||
|
<button type="button" role="tab" aria-selected={mode === 'passage'} onClick={() => setMode('passage')} className={tab('passage')}>Another passage</button>
|
||||||
|
<button type="button" role="tab" aria-selected={mode === 'translations'} onClick={() => setMode('translations')} className={tab('translations')}>Translations</button>
|
||||||
|
</div>
|
||||||
|
{mode === 'passage' && (
|
||||||
|
<select aria-label="Compare with" value={otherKey} onChange={(e) => setOtherKey(e.target.value)}
|
||||||
|
className="order-last min-w-0 basis-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm sm:order-none sm:basis-0 sm:flex-1">
|
||||||
|
<option value="">Choose a passage to set beside this one…</option>
|
||||||
|
{all.filter((c) => c.key !== mine?.key).map((c) => <option key={c.key} value={c.key}>{c.label}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<button type="button" onClick={onClose} className="ml-auto rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-sm hover:bg-black/5">Close</button>
|
||||||
|
</div>
|
||||||
|
{mode === 'passage' ? (
|
||||||
|
<div className="grid min-h-0 flex-1 overflow-y-auto md:grid-cols-2 md:divide-x md:divide-[var(--r-line)]">
|
||||||
|
<Column entry={mine} />
|
||||||
|
<Column entry={other} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
<h3 className="px-4 pt-4 font-medium">{mine?.label}</h3>
|
||||||
|
<TranslationsView project={project} chunkEntry={mine} updateChunk={updateChunk} />
|
||||||
|
{mine && <OiaBoxes chunk={mine.chunk} updateChunk={updateChunk} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// "What changed since my first draft": your Observe / Interpret / Apply notes now, with the words added or
|
||||||
|
// reworded since the copy kept when you first opened commentary. It shows whether commentary changed your thinking.
|
||||||
|
import { diffVerses } from '../../utils/textDiff.js';
|
||||||
|
import { markRanges } from '../../utils/compareTranslations.js';
|
||||||
|
|
||||||
|
const FIELDS = [['observation', 'Observe'], ['interpretation', 'Interpret'], ['application', 'Apply']];
|
||||||
|
const count = (s) => (s ?? '').trim().split(/\s+/).filter(Boolean).length;
|
||||||
|
|
||||||
|
export default function FirstDraft({ chunk }) {
|
||||||
|
const draft = chunk.firstDraft;
|
||||||
|
if (!draft) return null;
|
||||||
|
const rows = FIELDS.map(([f, label]) => {
|
||||||
|
const then = draft[f] ?? '';
|
||||||
|
const now = chunk[f] ?? '';
|
||||||
|
return { f, label, then, now, same: then.trim() === now.trim(), parts: markRanges(now, diffVerses(then, now).b) };
|
||||||
|
}).filter((r) => r.then.trim() || r.now.trim());
|
||||||
|
const changed = rows.filter((r) => !r.same).length;
|
||||||
|
return (
|
||||||
|
<details className="rounded-xl border border-[var(--r-line)] p-3 text-xs text-[var(--r-sub)]">
|
||||||
|
<summary className="cursor-pointer">First draft, before commentary <span className="opacity-70">· {new Date(draft.at).toLocaleDateString()} · {changed === 0 ? 'nothing changed since' : `${changed} of ${rows.length} notes changed since`}</span></summary>
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
{rows.length === 0 && <p>You opened commentary before writing anything.</p>}
|
||||||
|
{rows.map((r) => (
|
||||||
|
<div key={r.f}>
|
||||||
|
<p className="font-medium text-[var(--r-text)]">{r.label} <span className="font-normal text-[var(--r-sub)]">· {count(r.then)} words then, {count(r.now)} now</span></p>
|
||||||
|
{r.same
|
||||||
|
? <p className="mt-0.5">Unchanged.</p>
|
||||||
|
: <p className="mt-0.5 whitespace-pre-line font-serif text-[13px] leading-relaxed text-[var(--r-text)]">{r.parts.map((p, i) => (p.mark ? <mark key={i} className="rounded bg-[var(--r-hl)] px-0.5 text-inherit">{p.text}</mark> : <span key={i}>{p.text}</span>))}</p>}
|
||||||
|
{!r.same && r.then.trim() && <details className="mt-1"><summary className="cursor-pointer">Show the first draft</summary><p className="mt-1 whitespace-pre-line font-serif text-[13px] leading-relaxed">{r.then}</p></details>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<p className="opacity-70">Highlighted words are new or reworded since the draft.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,16 @@
|
|||||||
// Observe / Interpret / Apply as an accordion (one open at a time), plus a "More"
|
// Observe / Interpret / Apply as an accordion (one open at a time), plus a "More"
|
||||||
// area for background notes and tags so the page stays quiet.
|
// area for background notes and tags so the page stays quiet.
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import SessionNotes from './SessionNotes.jsx';
|
||||||
|
import FirstDraft from './FirstDraft.jsx';
|
||||||
|
import ChunkComments from './ChunkComments.jsx';
|
||||||
|
import { openCommentCount } from '../../utils/chunkComments.js';
|
||||||
|
import { bookBackground, hasBookInfo } from '../../utils/bookInfo.js';
|
||||||
|
import ScriptureRefs from '../ScriptureRefs.jsx';
|
||||||
|
import { usePasteCheck } from './PastePrompt.jsx';
|
||||||
|
import { listNoteVersions, recordNoteVersion } from '../../utils/noteHistory.js';
|
||||||
import DictateButton, { appendSpoken } from '../DictateButton.jsx';
|
import DictateButton, { appendSpoken } from '../DictateButton.jsx';
|
||||||
|
import { ownShare, ownWords, pastedWords, clearPastes } from '../../utils/ownWork.js';
|
||||||
import { templateFromChunk, templatePrompt } from '../../utils/studyTemplates.js';
|
import { templateFromChunk, templatePrompt } from '../../utils/studyTemplates.js';
|
||||||
|
|
||||||
const SECTIONS = [
|
const SECTIONS = [
|
||||||
@@ -16,11 +25,25 @@ const SECTIONS = [
|
|||||||
const areaCls = 'w-full resize-y rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3.5 py-3 font-serif text-[15px] leading-7 text-[var(--r-text)] placeholder:font-sans placeholder:text-[13px] placeholder:leading-5 placeholder:text-[var(--r-sub)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
|
const areaCls = 'w-full resize-y rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3.5 py-3 font-serif text-[15px] leading-7 text-[var(--r-text)] placeholder:font-sans placeholder:text-[13px] placeholder:leading-5 placeholder:text-[var(--r-sub)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
|
||||||
const words = (s) => (s ?? '').trim().split(/\s+/).filter(Boolean).length;
|
const words = (s) => (s ?? '').trim().split(/\s+/).filter(Boolean).length;
|
||||||
|
|
||||||
export default function NotesPane({ chunk, updateChunk, allTags, templateId, saveTemplate, notify }) {
|
export default function NotesPane({ chunk, updateChunk, allTags, templateId, saveTemplate, notify, onPray, focusRequest, author, bookAbbrev, bookName }) {
|
||||||
const [open, setOpen] = useState(() => {
|
const [open, setOpen] = useState(() => {
|
||||||
try { return localStorage.getItem('studyNotesOpen') || 'observation'; } catch { return 'observation'; }
|
try { return localStorage.getItem('studyNotesOpen') || 'observation'; } catch { return 'observation'; }
|
||||||
});
|
});
|
||||||
const [more, setMore] = useState(false);
|
const [more, setMore] = useState(false);
|
||||||
|
// A keyboard jump (1 / 2 / 3 on the Study page): open that note and put the cursor in it.
|
||||||
|
const areas = useRef({});
|
||||||
|
const wanted = useRef(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focusRequest) return;
|
||||||
|
wanted.current = focusRequest.field;
|
||||||
|
setOpen(focusRequest.field);
|
||||||
|
}, [focusRequest]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (wanted.current && wanted.current === open) {
|
||||||
|
const el = areas.current[open];
|
||||||
|
if (el) { el.focus(); const end = el.value.length; el.setSelectionRange(end, end); wanted.current = null; }
|
||||||
|
}
|
||||||
|
});
|
||||||
const [tplName, setTplName] = useState('');
|
const [tplName, setTplName] = useState('');
|
||||||
const [tplOpen, setTplOpen] = useState(false);
|
const [tplOpen, setTplOpen] = useState(false);
|
||||||
const [tagInput, setTagInput] = useState('');
|
const [tagInput, setTagInput] = useState('');
|
||||||
@@ -36,11 +59,39 @@ 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 pasteCheck = usePasteCheck(chunk, updateChunk);
|
||||||
|
// Earlier versions of one note: kept when you start and stop editing it; "History" lists them with Restore.
|
||||||
|
const [historyField, setHistoryField] = useState(null);
|
||||||
|
const [versions, setVersions] = useState([]);
|
||||||
|
useEffect(() => { setHistoryField(null); }, [chunk.id]);
|
||||||
|
const keepVersion = (field) => {
|
||||||
|
const list = recordNoteVersion(chunk.id, field, chunk[field]);
|
||||||
|
if (historyField === field) setVersions(list);
|
||||||
|
};
|
||||||
|
const openHistory = (field) => {
|
||||||
|
if (historyField === field) { setHistoryField(null); return; }
|
||||||
|
recordNoteVersion(chunk.id, field, chunk[field]);
|
||||||
|
setVersions(listNoteVersions(chunk.id, field));
|
||||||
|
setHistoryField(field);
|
||||||
|
};
|
||||||
|
const restore = (field, text) => {
|
||||||
|
recordNoteVersion(chunk.id, field, chunk[field]); // so restoring can itself be undone
|
||||||
|
updateChunk(chunk.id, { [field]: text });
|
||||||
|
setVersions(listNoteVersions(chunk.id, field));
|
||||||
|
};
|
||||||
|
|
||||||
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,13 +101,41 @@ 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 ref={(el) => { areas.current[field] = el; }} aria-label={label} value={chunk[field] ?? ''} rows={7} placeholder={templatePrompt(templateId, field) ?? placeholder}
|
||||||
onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })} className={areaCls} />
|
onFocus={() => keepVersion(field)} onBlur={() => keepVersion(field)} onPaste={pasteCheck.onPaste(field)} onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })} className={areaCls} />
|
||||||
<div className="mt-1.5"><DictateButton label={`Dictate ${label.toLowerCase()}`} onText={(said) => updateChunk(chunk.id, { [field]: appendSpoken(chunk[field], said) })} /></div>
|
<ScriptureRefs text={chunk[field]} />
|
||||||
|
{pasteCheck.prompt(field)}
|
||||||
|
<button type="button" onClick={() => openHistory(field)} aria-expanded={historyField === field} className="mt-1.5 mr-3 text-xs text-[var(--r-sub)] hover:text-[var(--r-text)]">History</button>
|
||||||
|
{historyField === field && (
|
||||||
|
<ul aria-label={`${label} history`} className="mt-1.5 space-y-1.5 rounded-xl border border-[var(--r-line)] p-2 text-xs">
|
||||||
|
{versions.length === 0 && <li className="text-[var(--r-sub)]">No earlier versions yet. They are saved as you edit.</li>}
|
||||||
|
{versions.map((v) => (
|
||||||
|
<li key={v.at} className="flex items-baseline justify-between gap-3">
|
||||||
|
<span className="min-w-0"><span className="text-[var(--r-sub)]">{new Date(v.at).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })} · {words(v.text)} words · </span><span className="font-serif">{v.text.length > 70 ? `${v.text.slice(0, 70)}…` : v.text}</span></span>
|
||||||
|
<button type="button" onClick={() => restore(field, v.text)} disabled={v.text === chunk[field]} className="shrink-0 text-[var(--r-accent)] hover:underline disabled:opacity-40 disabled:no-underline">Restore</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
{field === 'application' && onPray && (
|
||||||
|
<button type="button" onClick={onPray} className="mt-1.5 mr-3 text-xs text-[var(--r-accent)] hover:underline">Pray about this →</button>
|
||||||
|
)}
|
||||||
|
<div className="mt-1.5 inline-block"><DictateButton label={`Dictate ${label.toLowerCase()}`} onText={(said) => updateChunk(chunk.id, { [field]: appendSpoken(chunk[field], said) })} /></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -66,7 +145,7 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
|
|||||||
<div className="rounded-2xl border border-[var(--r-line)]">
|
<div className="rounded-2xl border border-[var(--r-line)]">
|
||||||
<button type="button" onClick={() => setMore((m) => !m)} aria-expanded={more}
|
<button type="button" onClick={() => setMore((m) => !m)} aria-expanded={more}
|
||||||
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm">
|
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm">
|
||||||
<span><span className="font-medium">More</span> <span className="text-xs text-[var(--r-sub)]">background{tags.length ? ` · ${tags.length} tag${tags.length === 1 ? '' : 's'}` : ' and tags'}</span></span>
|
<span><span className="font-medium">More</span> <span className="text-xs text-[var(--r-sub)]">background{tags.length ? ` · ${tags.length} tag${tags.length === 1 ? '' : 's'}` : ' and tags'}{openCommentCount(chunk.comments) ? ` · ${openCommentCount(chunk.comments)} open comment${openCommentCount(chunk.comments) === 1 ? '' : 's'}` : ''}</span></span>
|
||||||
<span className="text-xs text-[var(--r-sub)]">{more ? '▾' : '▸'}</span>
|
<span className="text-xs text-[var(--r-sub)]">{more ? '▾' : '▸'}</span>
|
||||||
</button>
|
</button>
|
||||||
{more && (
|
{more && (
|
||||||
@@ -76,12 +155,20 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
|
|||||||
<textarea value={chunk.generalNotes ?? ''} rows={5} onChange={(e) => updateChunk(chunk.id, { generalNotes: e.target.value })}
|
<textarea value={chunk.generalNotes ?? ''} rows={5} onChange={(e) => updateChunk(chunk.id, { generalNotes: e.target.value })}
|
||||||
placeholder="e.g. author, date, audience, historical context, key themes…" className={`mt-1 ${areaCls}`} />
|
placeholder="e.g. author, date, audience, historical context, key themes…" className={`mt-1 ${areaCls}`} />
|
||||||
<span className="mt-1 block"><DictateButton label="Dictate background" onText={(said) => updateChunk(chunk.id, { generalNotes: appendSpoken(chunk.generalNotes, said) })} /></span>
|
<span className="mt-1 block"><DictateButton label="Dictate background" onText={(said) => updateChunk(chunk.id, { generalNotes: appendSpoken(chunk.generalNotes, said) })} /></span>
|
||||||
|
<ScriptureRefs text={chunk.generalNotes} />
|
||||||
|
{hasBookInfo(bookAbbrev) && !(chunk.generalNotes ?? '').includes('at a glance') && (
|
||||||
|
<button type="button" onClick={() => updateChunk(chunk.id, { generalNotes: `${(chunk.generalNotes ?? '').trimEnd()}${chunk.generalNotes?.trim() ? '\n\n' : ''}${bookBackground(bookAbbrev, bookName)}` })}
|
||||||
|
className="mt-1 block text-xs text-[var(--r-accent)] hover:underline">Add {bookName ?? 'book'} at a glance (author, date, theme)</button>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
<label className="block text-xs text-[var(--r-sub)]">
|
<label className="block text-xs text-[var(--r-sub)]">
|
||||||
Discussion questions <span className="opacity-70">(one per line; printed on the lesson handout)</span>
|
Discussion questions <span className="opacity-70">(one per line; printed on the lesson handout)</span>
|
||||||
<textarea value={chunk.discussionQuestions ?? ''} rows={4} onChange={(e) => updateChunk(chunk.id, { discussionQuestions: e.target.value })}
|
<textarea value={chunk.discussionQuestions ?? ''} rows={4} onChange={(e) => updateChunk(chunk.id, { discussionQuestions: e.target.value })}
|
||||||
placeholder={'What does this passage say about God?\nWhere do you see this in your own life?'} className={`mt-1 ${areaCls}`} />
|
placeholder={'What does this passage say about God?\nWhere do you see this in your own life?'} className={`mt-1 ${areaCls}`} />
|
||||||
</label>
|
</label>
|
||||||
|
<ChunkComments chunk={chunk} updateChunk={updateChunk} author={author} />
|
||||||
|
<FirstDraft chunk={chunk} />
|
||||||
|
<SessionNotes chunk={chunk} updateChunk={updateChunk} />
|
||||||
{saveTemplate && (
|
{saveTemplate && (
|
||||||
<div className="rounded-xl border border-dashed border-[var(--r-line)] p-3">
|
<div className="rounded-xl border border-dashed border-[var(--r-line)] p-3">
|
||||||
{!tplOpen ? (
|
{!tplOpen ? (
|
||||||
|
|||||||
@@ -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">
|
||||||
|
{tab ? (
|
||||||
|
<button type="button" onClick={() => setOpen((o) => !o)} aria-label="Account menu" aria-expanded={open}
|
||||||
|
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)]'}`}>
|
||||||
|
<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}
|
<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">
|
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] ?? '·'}
|
{authUser?.email?.[0] ?? '·'}
|
||||||
</button>
|
</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-bar 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');
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// When a long block of text is pasted into a note, ask where it came from. Pastes are remembered as "not typed by
|
||||||
|
// me" straight away (so forgetting to answer never counts as your own work); saying you wrote it takes that back.
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { withPaste, withoutPaste, PASTE_MIN_WORDS } from '../../utils/ownWork.js';
|
||||||
|
|
||||||
|
const LABELS = { observation: 'Observe', interpretation: 'Interpret', application: 'Apply' };
|
||||||
|
const wordsIn = (s) => s.trim().split(/\s+/).filter(Boolean).length;
|
||||||
|
|
||||||
|
export function usePasteCheck(chunk, updateChunk) {
|
||||||
|
const [pending, setPending] = useState(null); // { field, text }
|
||||||
|
useEffect(() => { setPending(null); }, [chunk.id]);
|
||||||
|
|
||||||
|
const onPaste = (field) => (e) => {
|
||||||
|
const text = e.clipboardData?.getData('text') ?? '';
|
||||||
|
const next = withPaste(chunk, field, text);
|
||||||
|
if (!next) return;
|
||||||
|
updateChunk(chunk.id, { aiPastes: next });
|
||||||
|
setPending({ field, text: text.trim() });
|
||||||
|
};
|
||||||
|
const wroteIt = () => {
|
||||||
|
if (pending) updateChunk(chunk.id, { aiPastes: withoutPaste(chunk, pending.field, pending.text) });
|
||||||
|
setPending(null);
|
||||||
|
};
|
||||||
|
const prompt = (field) => (pending && pending.field === field ? <PastePrompt field={field} words={wordsIn(pending.text)} onKeep={() => setPending(null)} onWroteIt={wroteIt} /> : null);
|
||||||
|
return { onPaste, prompt };
|
||||||
|
}
|
||||||
|
|
||||||
|
function PastePrompt({ field, words, onKeep, onWroteIt }) {
|
||||||
|
const btn = 'rounded-full border border-[var(--r-text)] px-3 py-1 text-xs hover:bg-black/5';
|
||||||
|
return (
|
||||||
|
<div role="group" aria-label="Where did this paste come from?" className="mt-1.5 rounded-xl border border-[var(--r-accent)] bg-[var(--r-hl)] p-3 text-xs">
|
||||||
|
<p className="font-medium text-[var(--r-text)]">You pasted {words} words into {LABELS[field] ?? 'this note'}. Is this from AI?</p>
|
||||||
|
<p className="mt-0.5 text-[var(--r-sub)]">Pasted text isn’t counted as your own work. Choose “I wrote it” if it came from you.</p>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
<button type="button" onClick={onKeep} className={`${btn} bg-[var(--r-text)] text-[var(--r-bg)]`}>Yes, from AI</button>
|
||||||
|
<button type="button" onClick={onKeep} className={btn}>No, a book or website</button>
|
||||||
|
<button type="button" onClick={onWroteIt} className={btn}>No, I wrote it</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 text-[11px] text-[var(--r-sub)]">Pastes under {PASTE_MIN_WORDS} words aren’t asked about.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// A section of the Study page lifted into a large window over the page, so there's room to read commentary or
|
||||||
|
// write notes without the column's cramped width. Esc, the backdrop or Close puts it back.
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
export default function PopOut({ title, onClose, children }) {
|
||||||
|
const panel = useRef(null);
|
||||||
|
useEffect(() => { panel.current?.focus(); }, []);
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[70]">
|
||||||
|
<div className="absolute inset-0 bg-black/40" onClick={onClose} aria-hidden="true" />
|
||||||
|
<div ref={panel} role="dialog" aria-modal="true" aria-label={title} tabIndex={-1}
|
||||||
|
className="absolute inset-2 flex flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] shadow-2xl focus:outline-none sm:inset-4 lg:inset-x-[7vw] lg:inset-y-5 xl:inset-x-[12vw]">
|
||||||
|
<div className="flex shrink-0 items-center justify-between border-b border-[var(--r-line)] px-4 py-2.5">
|
||||||
|
<h2 className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">{title}</h2>
|
||||||
|
<button type="button" onClick={onClose} className="rounded-lg border border-[var(--r-line)] px-3 py-1 text-xs hover:bg-black/5">Close (Esc)</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// A reading list for the whole study: the books, commentaries and articles you plan to consult, with a note on
|
||||||
|
// each and a tick when done. It belongs to the study, not one chunk.
|
||||||
|
import { makeId } from '../../utils/bibleUtils.jsx';
|
||||||
|
|
||||||
|
export default function ReadingListTab({ project, updateProject }) {
|
||||||
|
const items = project?.readingList ?? [];
|
||||||
|
const set = (next) => updateProject((current) => ({ ...current, readingList: next(current.readingList ?? []) }));
|
||||||
|
const add = (title) => { const t = title.trim(); if (t) set((list) => [...list, { id: makeId(), title: t, note: '', done: false }]); };
|
||||||
|
const patch = (id, change) => set((list) => list.map((i) => (i.id === id ? { ...i, ...change } : i)));
|
||||||
|
const open = items.filter((i) => !i.done).length;
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<p className="text-xs text-[var(--r-sub)]">Books and articles to consult for this study{items.length ? ` · ${open} to read, ${items.length - open} done` : ''}. Read the passage and write your own notes first; this list is for afterwards.</p>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{items.map((i) => (
|
||||||
|
<li key={i.id} className="rounded-xl border border-[var(--r-line)] p-2.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input type="checkbox" aria-label={`Read: ${i.title}`} checked={!!i.done} onChange={() => patch(i.id, { done: !i.done })} className="accent-[var(--r-accent)]" />
|
||||||
|
<span className={`min-w-0 flex-1 ${i.done ? 'text-[var(--r-sub)] line-through' : 'font-medium'}`}>{i.title}</span>
|
||||||
|
<button type="button" aria-label={`Remove ${i.title}`} onClick={() => set((list) => list.filter((x) => x.id !== i.id))} className="text-xs text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]">×</button>
|
||||||
|
</div>
|
||||||
|
<input aria-label={`Note on ${i.title}`} value={i.note ?? ''} onChange={(e) => patch(i.id, { note: e.target.value })} placeholder="Where in it, or what to look for"
|
||||||
|
className="mt-1.5 w-full rounded-lg border border-transparent bg-transparent px-2 py-1 text-xs text-[var(--r-text)] placeholder:text-[var(--r-sub)] placeholder:opacity-60 hover:border-[var(--r-line)] focus:border-[var(--r-line)] focus:bg-[var(--r-bg)] focus:outline-none" />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<input aria-label="Add to the reading list" placeholder="Add a book or article, then Enter"
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); add(e.target.value); e.target.value = ''; } }}
|
||||||
|
className="w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,29 +1,45 @@
|
|||||||
// Right-hand pane of the Study stage: commentary, word study, cross-references and
|
// Right-hand pane of the Study stage: commentary, word study, cross-references and
|
||||||
// people & places, all driven by the selected chunk.
|
// people & places, all driven by the selected chunk.
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import CommentaryChunkTab from './CommentaryChunkTab.jsx';
|
import CommentaryChunkTab from './CommentaryChunkTab.jsx';
|
||||||
import WordStudyTab from './WordStudyTab.jsx';
|
import WordStudyTab from './WordStudyTab.jsx';
|
||||||
import CrossRefsTab from './CrossRefsTab.jsx';
|
import CrossRefsTab from './CrossRefsTab.jsx';
|
||||||
import PeopleChunkTab from './PeopleChunkTab.jsx';
|
import PeopleChunkTab from './PeopleChunkTab.jsx';
|
||||||
|
import ReadingListTab from './ReadingListTab.jsx';
|
||||||
import IqChunkAids from '../iq/IqChunkAids.jsx';
|
import IqChunkAids from '../iq/IqChunkAids.jsx';
|
||||||
import { useIqAvailable } from '../../hooks/useIq.js';
|
import { useIqAvailable } from '../../hooks/useIq.js';
|
||||||
|
import { OBSERVE_FIRST_MIN, observeFirstDone, ownWords } from '../../utils/ownWork.js';
|
||||||
|
|
||||||
export const RESOURCE_TABS = [
|
export const RESOURCE_TABS = [
|
||||||
{ id: 'commentary', label: 'Commentary' },
|
{ id: 'commentary', label: 'Commentary' },
|
||||||
{ id: 'words', label: 'Word study' },
|
{ id: 'words', label: 'Word study' },
|
||||||
{ id: 'refs', label: 'Cross-refs' },
|
{ id: 'refs', label: 'Cross-refs' },
|
||||||
{ id: 'people', label: 'People & places' },
|
{ id: 'people', label: 'People & places' },
|
||||||
|
{ id: 'readings', label: 'Reading list' },
|
||||||
{ id: 'aids', label: 'Study aids', needsIq: true }, // shown only when the server has an IQ Bible key
|
{ id: 'aids', label: 'Study aids', needsIq: true }, // shown only when the server has an IQ Bible key
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex, project, updateChunk, notify, resources, wordStudy, crossRefs, openWordId, setOpenWordId }) {
|
// leadTab: { id, label, node } shown as the first tab, in place of the resources, when it is the selected one (the Study page
|
||||||
|
// puts the notes there, so notes and resources share one column).
|
||||||
|
export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex, project, updateChunk, updateProject, notify, resources, wordStudy, crossRefs, openWordId, setOpenWordId, leadTab }) {
|
||||||
const iq = useIqAvailable();
|
const iq = useIqAvailable();
|
||||||
const tabs = RESOURCE_TABS.filter((t) => !t.needsIq || iq);
|
const tabs = [...(leadTab ? [leadTab] : []), ...RESOURCE_TABS.filter((t) => !t.needsIq || iq)];
|
||||||
if (tab === 'aids' && !iq) tab = 'commentary';
|
if (tab === 'aids' && !iq) tab = 'commentary';
|
||||||
const counts = { words: chunk.greekWords.length, refs: (chunk.crossReferences ?? []).length };
|
// Commentary-type tabs wait for your own Observe notes (per chunk, until you choose to look anyway).
|
||||||
|
const [unlocked, setUnlocked] = useState(() => new Set());
|
||||||
|
useEffect(() => { setUnlocked(new Set()); }, [chunk.id]);
|
||||||
|
const held = (tab === 'commentary' || tab === 'aids') && !observeFirstDone(chunk) && !unlocked.has(chunk.id);
|
||||||
|
// The first time commentary or study aids are on screen for a chunk, keep a copy of your notes as they were,
|
||||||
|
// so you can later see what changed after reading others (shown under "More" in the notes).
|
||||||
|
const seeing = (tab === 'commentary' || tab === 'aids') && !held;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!seeing || chunk.firstDraft) return;
|
||||||
|
updateChunk(chunk.id, { firstDraft: { at: Date.now(), observation: chunk.observation ?? '', interpretation: chunk.interpretation ?? '', application: chunk.application ?? '' } }, { touch: false });
|
||||||
|
}, [seeing, chunk.id, chunk.firstDraft]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
const counts = { words: chunk.greekWords.length, refs: (chunk.crossReferences ?? []).length, readings: (project?.readingList ?? []).filter((i) => !i.done).length };
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 flex-col">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
<div role="tablist" aria-label="Study resources" className="flex shrink-0 flex-wrap gap-1.5 border-b border-[var(--r-line)] px-3 py-2.5">
|
<div role="tablist" aria-label="Study resources" className={`flex shrink-0 flex-wrap gap-1.5 border-b border-[var(--r-line)] px-3 py-2.5 ${leadTab ? 'sm:pr-11' : ''}`}>
|
||||||
{tabs.map((t) => (
|
{tabs.map((t) => (
|
||||||
<button key={t.id} type="button" role="tab" aria-selected={tab === t.id} onClick={() => setTab(t.id)}
|
<button key={t.id} type="button" role="tab" aria-selected={tab === t.id} onClick={() => setTab(t.id)}
|
||||||
className={`rounded-full border px-3 py-1 text-xs ${tab === t.id ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
|
className={`rounded-full border px-3 py-1 text-xs ${tab === t.id ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
|
||||||
@@ -32,11 +48,20 @@ export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div role="tabpanel" className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
<div role="tabpanel" className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
||||||
{tab === 'commentary' && <CommentaryChunkTab resources={resources} chunk={chunk} project={project} chapterIndex={chapterIndex} updateChunk={updateChunk} notify={notify} />}
|
{leadTab && tab === leadTab.id && leadTab.node}
|
||||||
|
{held && (
|
||||||
|
<div role="note" className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] p-4 text-sm">
|
||||||
|
<p className="font-medium">Observe first</p>
|
||||||
|
<p className="mt-1 text-xs text-[var(--r-sub)]">Write what you notice in the text before reading what others say. Commentary opens after {OBSERVE_FIRST_MIN} words of your own Observe notes ({ownWords(chunk, 'observation')} so far).</p>
|
||||||
|
<button type="button" onClick={() => setUnlocked((u) => new Set(u).add(chunk.id))} className="mt-2 text-xs text-[var(--r-accent)] hover:underline">Show it anyway</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!held && tab === 'commentary' && <CommentaryChunkTab resources={resources} chunk={chunk} project={project} chapterIndex={chapterIndex} updateChunk={updateChunk} notify={notify} />}
|
||||||
{tab === 'words' && <WordStudyTab wordStudy={wordStudy} chunk={chunk} openWordId={openWordId} setOpenWordId={setOpenWordId} />}
|
{tab === 'words' && <WordStudyTab wordStudy={wordStudy} chunk={chunk} openWordId={openWordId} setOpenWordId={setOpenWordId} />}
|
||||||
{tab === 'refs' && <CrossRefsTab crossRefs={crossRefs} chunk={chunk} />}
|
{tab === 'refs' && <CrossRefsTab crossRefs={crossRefs} chunk={chunk} />}
|
||||||
{tab === 'people' && <PeopleChunkTab book={chapter.bookAbbrev} chapter={chapter.chapter} chunk={chunk} />}
|
{tab === 'people' && <PeopleChunkTab book={chapter.bookAbbrev} chapter={chapter.chapter} chunk={chunk} />}
|
||||||
{tab === 'aids' && <IqChunkAids chunk={chunk} chapter={chapter} updateChunk={updateChunk} notify={notify} />}
|
{tab === 'readings' && updateProject && <ReadingListTab project={project} updateProject={updateProject} />}
|
||||||
|
{!held && tab === 'aids' && <IqChunkAids chunk={chunk} chapter={chapter} updateChunk={updateChunk} notify={notify} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -46,8 +71,8 @@ export function useResourceTab() {
|
|||||||
const [tab, setTabState] = useState(() => {
|
const [tab, setTabState] = useState(() => {
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem('studyResourceTab');
|
const saved = localStorage.getItem('studyResourceTab');
|
||||||
return RESOURCE_TABS.some((t) => t.id === saved) ? saved : 'commentary';
|
return saved === 'notes' || RESOURCE_TABS.some((t) => t.id === saved) ? saved : 'notes';
|
||||||
} catch { return 'commentary'; }
|
} catch { return 'notes'; }
|
||||||
});
|
});
|
||||||
const setTab = (id) => {
|
const setTab = (id) => {
|
||||||
setTabState(id);
|
setTabState(id);
|
||||||
|
|||||||
@@ -2,9 +2,13 @@
|
|||||||
// interlinear words; click an original word to add it to the chunk's word study.
|
// interlinear words; click an original word to add it to the chunk's word study.
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { NT_BOOK_NUMBER } from '../../utils/bibleUtils.jsx';
|
import { NT_BOOK_NUMBER } from '../../utils/bibleUtils.jsx';
|
||||||
|
import GrammarPanel from '../GrammarPanel.jsx';
|
||||||
|
import { markUnits } from '../../utils/biblicalUnits.js';
|
||||||
|
|
||||||
export default function ScripturePane({ verses, chapter, interlinear, interlinearLoading, addedStrongs, onPickWord }) {
|
export default function ScripturePane({ verses, chapter, interlinear, interlinearLoading, addedStrongs, onPickWord, verseNotes = {}, onVerseNote }) {
|
||||||
const [openVerse, setOpenVerse] = useState(null);
|
const [openVerse, setOpenVerse] = useState(null);
|
||||||
|
const [editingNote, setEditingNote] = useState(null);
|
||||||
|
const [inspect, setInspect] = useState(null); // the original word whose grammar is showing: { key, index }
|
||||||
const isHebrew = chapter && !NT_BOOK_NUMBER[chapter.bookAbbrev];
|
const isHebrew = chapter && !NT_BOOK_NUMBER[chapter.bookAbbrev];
|
||||||
return (
|
return (
|
||||||
<section aria-label="Scripture" className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] px-5 py-4">
|
<section aria-label="Scripture" className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] px-5 py-4">
|
||||||
@@ -19,13 +23,27 @@ export default function ScripturePane({ verses, chapter, interlinear, interlinea
|
|||||||
className={`mr-1.5 align-super font-sans text-[0.6em] ${words ? 'text-[var(--r-accent)] hover:underline' : 'cursor-default text-[var(--r-sub)]'} ${open ? 'font-bold' : ''}`}>
|
className={`mr-1.5 align-super font-sans text-[0.6em] ${words ? 'text-[var(--r-accent)] hover:underline' : 'cursor-default text-[var(--r-sub)]'} ${open ? 'font-bold' : ''}`}>
|
||||||
{v.chapter !== chapter?.chapter ? `${v.chapter}:` : ''}{v.number}
|
{v.chapter !== chapter?.chapter ? `${v.chapter}:` : ''}{v.number}
|
||||||
</button>
|
</button>
|
||||||
<span>{v.text}</span>
|
<span>{markUnits(v.text).map((part, i) => (part.note
|
||||||
|
? <abbr key={i} title={part.note} tabIndex={0} className="cursor-help underline decoration-dotted decoration-[var(--r-accent)] underline-offset-4">{part.text}</abbr>
|
||||||
|
: <span key={i}>{part.text}</span>))}</span>
|
||||||
|
{onVerseNote && (
|
||||||
|
<button type="button" onClick={() => setEditingNote(editingNote === `${v.chapter}:${v.number}` ? null : `${v.chapter}:${v.number}`)}
|
||||||
|
aria-label={`Note on verse ${v.number}`} title="Note on this verse"
|
||||||
|
className={`ml-1.5 align-baseline font-sans text-[0.7em] ${verseNotes[`${v.chapter}:${v.number}`] ? 'text-[var(--r-accent)]' : 'text-[var(--r-sub)] opacity-40 hover:opacity-100'}`}>✎</button>
|
||||||
|
)}
|
||||||
|
{(verseNotes[`${v.chapter}:${v.number}`] || editingNote === `${v.chapter}:${v.number}`) && (
|
||||||
|
<textarea aria-label={`Verse ${v.number} note`} value={verseNotes[`${v.chapter}:${v.number}`] ?? ''} rows={2} autoFocus={editingNote === `${v.chapter}:${v.number}`}
|
||||||
|
onChange={(e) => onVerseNote?.(`${v.chapter}:${v.number}`, e.target.value)}
|
||||||
|
placeholder="Your note on this verse"
|
||||||
|
className="mt-1 block w-full resize-y rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 font-sans text-[13px] leading-snug text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" />
|
||||||
|
)}
|
||||||
{open && words && (
|
{open && words && (
|
||||||
<div dir={isHebrew ? 'rtl' : 'ltr'} className="mt-2 flex flex-wrap gap-2 rounded-xl bg-[var(--r-bg)] p-2.5 font-sans">
|
<div dir={isHebrew ? 'rtl' : 'ltr'} className="mt-2 flex flex-wrap gap-2 rounded-xl bg-[var(--r-bg)] p-2.5 font-sans">
|
||||||
{words.map((w, i) => {
|
{words.map((w, i) => {
|
||||||
const added = w.s && addedStrongs.has(w.s.toUpperCase());
|
const added = w.s && addedStrongs.has(w.s.toUpperCase());
|
||||||
return (
|
return (
|
||||||
<button key={i} type="button" disabled={!w.s} onClick={() => onPickWord(w)}
|
<button key={i} type="button" disabled={!w.s} onClick={() => onPickWord(w)}
|
||||||
|
onMouseEnter={() => setInspect({ key: `${v.chapter}:${v.number}`, index: i })} onFocus={() => setInspect({ key: `${v.chapter}:${v.number}`, index: i })}
|
||||||
title={[w.t, w.s, w.p].filter(Boolean).join(' · ') + (w.s ? (added ? ' — already in word study' : ' — click to add') : '')}
|
title={[w.t, w.s, w.p].filter(Boolean).join(' · ') + (w.s ? (added ? ' — already in word study' : ' — click to add') : '')}
|
||||||
className={`flex flex-col items-center rounded-lg border px-2 py-1 text-center ${added ? 'border-[var(--r-accent)]' : 'border-[var(--r-line)] hover:bg-black/5'} disabled:cursor-default`}>
|
className={`flex flex-col items-center rounded-lg border px-2 py-1 text-center ${added ? 'border-[var(--r-accent)]' : 'border-[var(--r-line)] hover:bg-black/5'} disabled:cursor-default`}>
|
||||||
<span className={`text-base ${isHebrew ? 'font-serif' : ''}`}>{w.o}</span>
|
<span className={`text-base ${isHebrew ? 'font-serif' : ''}`}>{w.o}</span>
|
||||||
@@ -33,6 +51,9 @@ export default function ScripturePane({ verses, chapter, interlinear, interlinea
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{inspect?.key === `${v.chapter}:${v.number}` && words[inspect.index]?.p && (
|
||||||
|
<GrammarPanel label={words[inspect.index].p} className="basis-full" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// The "More" area's session-facing notes: a teach-it-back summary, who came, what the group said, and follow-ups.
|
||||||
|
import { makeId } from '../../utils/bibleUtils.jsx';
|
||||||
|
|
||||||
|
const areaCls = 'mt-1 w-full resize-y 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)]';
|
||||||
|
|
||||||
|
export default function SessionNotes({ chunk, updateChunk }) {
|
||||||
|
const followUps = chunk.followUps ?? [];
|
||||||
|
const setFollowUps = (next) => updateChunk(chunk.id, { followUps: next });
|
||||||
|
const add = (text) => { const t = text.trim(); if (t) setFollowUps([...followUps, { id: makeId(), text: t, done: false }]); };
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="block text-xs text-[var(--r-sub)]">
|
||||||
|
Teach it back <span className="opacity-70">(explain the passage in three sentences, as you would to someone else)</span>
|
||||||
|
<textarea value={chunk.teachBack ?? ''} rows={3} onChange={(e) => updateChunk(chunk.id, { teachBack: e.target.value })} className={areaCls} />
|
||||||
|
</label>
|
||||||
|
<label className="block text-xs text-[var(--r-sub)]">
|
||||||
|
Who came
|
||||||
|
<input value={chunk.attendees ?? ''} onChange={(e) => updateChunk(chunk.id, { attendees: e.target.value })} placeholder="Names, separated by commas"
|
||||||
|
className="mt-1 w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none" />
|
||||||
|
</label>
|
||||||
|
<label className="block text-xs text-[var(--r-sub)]">
|
||||||
|
What the group said
|
||||||
|
<textarea value={chunk.groupNotes ?? ''} rows={3} onChange={(e) => updateChunk(chunk.id, { groupNotes: e.target.value })} className={areaCls} />
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-[var(--r-sub)]">Follow-ups</p>
|
||||||
|
<ul className="mt-1 space-y-1">
|
||||||
|
{followUps.map((f) => (
|
||||||
|
<li key={f.id} className="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" aria-label={`Done: ${f.text}`} checked={!!f.done} onChange={() => setFollowUps(followUps.map((x) => (x.id === f.id ? { ...x, done: !x.done } : x)))} className="accent-[var(--r-accent)]" />
|
||||||
|
<span className={f.done ? 'text-[var(--r-sub)] line-through' : ''}>{f.text}</span>
|
||||||
|
<button type="button" aria-label={`Remove follow-up: ${f.text}`} onClick={() => setFollowUps(followUps.filter((x) => x.id !== f.id))} className="ml-auto text-xs text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]">×</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<input aria-label="Add a follow-up" placeholder="Something to follow up on, then Enter"
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); add(e.target.value); e.target.value = ''; } }}
|
||||||
|
className="mt-1.5 w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,14 +3,14 @@
|
|||||||
// 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' },
|
||||||
{ id: 'study', label: 'Study' },
|
{ id: 'study', label: 'Study' },
|
||||||
{ id: 'outline', label: 'Outline' },
|
{ id: 'outline', label: 'Outline' },
|
||||||
{ id: 'script', label: 'Script' },
|
{ id: 'script', label: 'Script' },
|
||||||
{ id: 'export', label: 'Export' },
|
{ id: 'export', label: 'Finish' },
|
||||||
];
|
];
|
||||||
const THEME_ORDER = ['paper', 'sepia', 'dark'];
|
const THEME_ORDER = ['paper', 'sepia', 'dark'];
|
||||||
|
|
||||||
@@ -27,23 +27,22 @@ 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="hidden min-w-0 max-w-[16rem] truncate text-sm font-medium sm:block">{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="flex min-w-0 flex-1 gap-0.5 overflow-x-auto sm:justify-center [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||||
<nav aria-label="Study stages" className="mx-auto flex gap-0.5">
|
|
||||||
{STAGES.map((s) => {
|
{STAGES.map((s) => {
|
||||||
const disabled = s.id !== 'setup' && (!project || noChunks);
|
const disabled = s.id !== 'setup' && (!project || noChunks);
|
||||||
return (
|
return (
|
||||||
<button key={s.id} type="button" disabled={disabled} aria-current={stage === s.id ? 'step' : undefined}
|
<button key={s.id} type="button" disabled={disabled} aria-current={stage === s.id ? 'step' : undefined}
|
||||||
title={disabled ? 'Add a chunk in Chunks first' : undefined}
|
title={disabled ? 'Add a chunk in Chunks first' : undefined}
|
||||||
onClick={() => setCurrentPage(s.id)}
|
onClick={() => setCurrentPage(s.id)}
|
||||||
className={`rounded-full px-3.5 py-1.5 text-xs ${stage === s.id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'} disabled:opacity-40 disabled:hover:bg-transparent`}>
|
className={`shrink-0 rounded-full px-3.5 py-1.5 text-xs ${stage === s.id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'} disabled:opacity-40 disabled:hover:bg-transparent`}>
|
||||||
{s.label}
|
{s.label}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="flex min-w-[7rem] items-center justify-end gap-2 text-xs text-[var(--r-sub)]" aria-live="polite">
|
<div className="flex shrink-0 items-center justify-end sm:min-w-[7rem] gap-2 text-xs text-[var(--r-sub)]" aria-live="polite">
|
||||||
{loading ? <span>Loading…</span>
|
{loading ? <span>Loading…</span>
|
||||||
: saveStatus ? <span className="text-[var(--r-accent)]">{saveStatus}</span>
|
: saveStatus ? <span className="text-[var(--r-accent)]">{saveStatus}</span>
|
||||||
: syncStatus === 'syncing' ? <span>Syncing…</span>
|
: syncStatus === 'syncing' ? <span>Syncing…</span>
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// "Where else is this word used?": the verses that share a Strong's number, by book, so a word study can show
|
||||||
|
// how an author (or the whole Bible) uses it. Books open to their verse list; Copy puts the whole list on the clipboard.
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { getStrongsOccurrences } from '../../syncService.js';
|
||||||
|
|
||||||
|
const btn = 'rounded-lg border border-[var(--r-line)] px-2.5 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50';
|
||||||
|
|
||||||
|
// "John 3:16, 17; 4:1" style list for one book.
|
||||||
|
export function refsText(book) {
|
||||||
|
const chapters = [];
|
||||||
|
for (const [chapter, verse] of book.refs) {
|
||||||
|
const last = chapters[chapters.length - 1];
|
||||||
|
if (last && last.chapter === chapter) last.verses.push(verse); else chapters.push({ chapter, verses: [verse] });
|
||||||
|
}
|
||||||
|
return `${book.name} ${chapters.map((c) => `${c.chapter}:${c.verses.join(', ')}`).join('; ')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function occurrencesText(strongs, data) {
|
||||||
|
const lines = data.books.map((b) => `${refsText(b)} (${b.count})`);
|
||||||
|
return `${strongs} appears in ${data.total} verse${data.total === 1 ? '' : 's'}:\n${lines.join('\n')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WordOccurrences({ strongs, onClose }) {
|
||||||
|
const [state, setState] = useState({ status: 'loading' });
|
||||||
|
const [open, setOpen] = useState(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setState({ status: 'loading' });
|
||||||
|
getStrongsOccurrences(strongs).then((r) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setState(r.ok ? { status: 'ready', data: r.data } : { status: 'error', error: r.error });
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [strongs]);
|
||||||
|
|
||||||
|
const copy = async () => {
|
||||||
|
try { await navigator.clipboard.writeText(occurrencesText(strongs, state.data)); setCopied(true); window.setTimeout(() => setCopied(false), 2000); } catch { /* clipboard unavailable */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] p-3" aria-label={`Where ${strongs} is used`}>
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<p className="text-sm font-medium">Where {strongs} is used</p>
|
||||||
|
<button type="button" onClick={onClose} className="ml-auto text-xs text-[var(--r-sub)] hover:underline">Close</button>
|
||||||
|
</div>
|
||||||
|
{state.status === 'loading' && <p className="text-xs text-[var(--r-sub)]">Counting… (the first look-up takes a few seconds)</p>}
|
||||||
|
{state.status === 'error' && <p role="alert" className="text-xs text-[var(--r-danger,#b42318)]">{state.error}</p>}
|
||||||
|
{state.status === 'ready' && (state.data.total === 0
|
||||||
|
? <p className="text-xs text-[var(--r-sub)]">No verses found for {strongs}.</p>
|
||||||
|
: (
|
||||||
|
<>
|
||||||
|
<p className="mb-2 text-xs text-[var(--r-sub)]">{state.data.total} verse{state.data.total === 1 ? '' : 's'} in {state.data.books.length} book{state.data.books.length === 1 ? '' : 's'}.</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{state.data.books.map((b) => (
|
||||||
|
<li key={b.book} className="rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)]">
|
||||||
|
<button type="button" onClick={() => setOpen(open === b.book ? null : b.book)} aria-expanded={open === b.book}
|
||||||
|
className="flex w-full items-center justify-between px-2.5 py-1.5 text-left text-sm">
|
||||||
|
<span>{b.name}</span><span className="text-xs text-[var(--r-sub)]">{b.count}</span>
|
||||||
|
</button>
|
||||||
|
{open === b.book && <p className="border-t border-[var(--r-line)] px-2.5 py-2 text-xs leading-relaxed">{refsText(b).slice(b.name.length + 1)}</p>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<button type="button" onClick={copy} className={`${btn} mt-2`}>{copied ? 'Copied!' : 'Copy the list'}</button>
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { test, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import WordOccurrences, { occurrencesText, refsText } from './WordOccurrences.jsx';
|
||||||
|
import { getStrongsOccurrences } from '../../syncService.js';
|
||||||
|
|
||||||
|
vi.mock('../../syncService.js', () => ({ getStrongsOccurrences: vi.fn() }));
|
||||||
|
|
||||||
|
const data = { strongs: 'G25', total: 4, books: [
|
||||||
|
{ book: 'JHN', name: 'John', count: 3, refs: [[3, 16], [3, 35], [14, 15]] },
|
||||||
|
{ book: 'ROM', name: 'Romans', count: 1, refs: [[5, 8]] },
|
||||||
|
] };
|
||||||
|
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
test('refs are grouped by chapter and the copied list names every book', () => {
|
||||||
|
expect(refsText(data.books[0])).toBe('John 3:16, 35; 14:15');
|
||||||
|
expect(occurrencesText('G25', data)).toBe('G25 appears in 4 verses:\nJohn 3:16, 35; 14:15 (3)\nRomans 5:8 (1)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows the books with counts and opens one to list its verses', async () => {
|
||||||
|
getStrongsOccurrences.mockResolvedValue({ ok: true, data });
|
||||||
|
render(<WordOccurrences strongs="G25" onClose={() => {}} />);
|
||||||
|
expect(await screen.findByText('4 verses in 2 books.')).toBeInTheDocument();
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /John/ }));
|
||||||
|
expect(screen.getByText('3:16, 35; 14:15')).toBeInTheDocument();
|
||||||
|
expect(getStrongsOccurrences).toHaveBeenCalledWith('G25');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('says so when the word is not found, and when the look-up fails', async () => {
|
||||||
|
getStrongsOccurrences.mockResolvedValueOnce({ ok: true, data: { strongs: 'G1', total: 0, books: [] } });
|
||||||
|
const { unmount } = render(<WordOccurrences strongs="G1" onClose={() => {}} />);
|
||||||
|
await waitFor(() => expect(screen.getByText('No verses found for G1.')).toBeInTheDocument());
|
||||||
|
unmount();
|
||||||
|
getStrongsOccurrences.mockResolvedValueOnce({ ok: false, error: 'Could not look up that word.' });
|
||||||
|
render(<WordOccurrences strongs="G2" onClose={() => {}} />);
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('Could not look up that word.');
|
||||||
|
});
|
||||||
@@ -6,6 +6,9 @@ import { externalLookupLinks, speakOriginalWord } from '../../utils/wordStudy.js
|
|||||||
import { useIqAvailable } from '../../hooks/useIq.js';
|
import { useIqAvailable } from '../../hooks/useIq.js';
|
||||||
import { normalizeStrongs } from '../../utils/iqbible.js';
|
import { normalizeStrongs } from '../../utils/iqbible.js';
|
||||||
import IqLexicon from '../iq/IqLexicon.jsx';
|
import IqLexicon from '../iq/IqLexicon.jsx';
|
||||||
|
import GrammarPanel from '../GrammarPanel.jsx';
|
||||||
|
import IqDictionary from '../iq/IqDictionary.jsx';
|
||||||
|
import WordOccurrences from './WordOccurrences.jsx';
|
||||||
|
|
||||||
const inputCls = 'mt-1 block w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
|
const inputCls = 'mt-1 block w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
|
||||||
const btn = 'rounded-lg border border-[var(--r-line)] px-2.5 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50';
|
const btn = 'rounded-lg border border-[var(--r-line)] px-2.5 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50';
|
||||||
@@ -14,6 +17,7 @@ function WordCard({ word, open, onToggle, chunkId, wordStudy }) {
|
|||||||
const { updateChunkWord, lookupWord, removeGreekWord } = wordStudy;
|
const { updateChunkWord, lookupWord, removeGreekWord } = wordStudy;
|
||||||
const iq = useIqAvailable();
|
const iq = useIqAvailable();
|
||||||
const [lexicon, setLexicon] = useState(false);
|
const [lexicon, setLexicon] = useState(false);
|
||||||
|
const [where, setWhere] = useState(false);
|
||||||
const strongs = normalizeStrongs(word.strongNumber || word.query);
|
const strongs = normalizeStrongs(word.strongNumber || word.query);
|
||||||
const set = (patch) => updateChunkWord(chunkId, word.id, patch);
|
const set = (patch) => updateChunkWord(chunkId, word.id, patch);
|
||||||
const field = (label, key, placeholder) => (
|
const field = (label, key, placeholder) => (
|
||||||
@@ -32,6 +36,7 @@ function WordCard({ word, open, onToggle, chunkId, wordStudy }) {
|
|||||||
{open && (
|
{open && (
|
||||||
<div className="space-y-3 border-t border-[var(--r-line)] px-3 py-3">
|
<div className="space-y-3 border-t border-[var(--r-line)] px-3 py-3">
|
||||||
{lexicon && iq && strongs && <IqLexicon strongs={strongs} onClose={() => setLexicon(false)} />}
|
{lexicon && iq && strongs && <IqLexicon strongs={strongs} onClose={() => setLexicon(false)} />}
|
||||||
|
{where && strongs && <WordOccurrences strongs={strongs} onClose={() => setWhere(false)} />}
|
||||||
<label className="block text-xs text-[var(--r-sub)]">Strong’s number or English gloss
|
<label className="block text-xs text-[var(--r-sub)]">Strong’s number or English gloss
|
||||||
<input type="text" value={word.query} onChange={(e) => set({ query: e.target.value })}
|
<input type="text" value={word.query} onChange={(e) => set({ query: e.target.value })}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); lookupWord(chunkId, word.id, 'greek'); } }}
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); lookupWord(chunkId, word.id, 'greek'); } }}
|
||||||
@@ -41,7 +46,8 @@ function WordCard({ word, open, onToggle, chunkId, wordStudy }) {
|
|||||||
<button type="button" onClick={() => lookupWord(chunkId, word.id, 'greek')} className={`${btn} bg-[var(--r-text)] text-[var(--r-bg)] hover:opacity-90`}>{word.loading ? 'Looking up...' : 'Look up Greek'}</button>
|
<button type="button" onClick={() => lookupWord(chunkId, word.id, 'greek')} className={`${btn} bg-[var(--r-text)] text-[var(--r-bg)] hover:opacity-90`}>{word.loading ? 'Looking up...' : 'Look up Greek'}</button>
|
||||||
<button type="button" onClick={() => lookupWord(chunkId, word.id, 'hebrew')} className={btn}>{word.loading ? 'Looking up...' : 'Look up Hebrew'}</button>
|
<button type="button" onClick={() => lookupWord(chunkId, word.id, 'hebrew')} className={btn}>{word.loading ? 'Looking up...' : 'Look up Hebrew'}</button>
|
||||||
{iq && strongs && <button type="button" onClick={() => setLexicon((o) => !o)} aria-pressed={lexicon} className={btn} title="Strong's, BDB, LSJ and Abbott-Smith entries">Lexicons</button>}
|
{iq && strongs && <button type="button" onClick={() => setLexicon((o) => !o)} aria-pressed={lexicon} className={btn} title="Strong's, BDB, LSJ and Abbott-Smith entries">Lexicons</button>}
|
||||||
{word.lexeme && <button type="button" onClick={() => speakOriginalWord(word.lexeme, word.strongNumber)} className={btn} title="Hear pronunciation">Speak</button>}
|
{strongs && <button type="button" onClick={() => setWhere((o) => !o)} aria-pressed={where} className={btn} title="Every verse that uses this word, by book">Where else</button>}
|
||||||
|
{word.lexeme && <button type="button" onClick={() => speakOriginalWord(word.lexeme, word.strongNumber, word.transliteration)} className={btn} title="Hear pronunciation">Speak</button>}
|
||||||
<button type="button" onClick={() => removeGreekWord(chunkId, word.id)} className={`${btn} ml-auto text-[var(--r-danger,#b42318)]`}>Delete</button>
|
<button type="button" onClick={() => removeGreekWord(chunkId, word.id)} className={`${btn} ml-auto text-[var(--r-danger,#b42318)]`}>Delete</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
@@ -50,6 +56,7 @@ function WordCard({ word, open, onToggle, chunkId, wordStudy }) {
|
|||||||
{field('Part of speech', 'partOfSpeech', 'e.g. Noun')}
|
{field('Part of speech', 'partOfSpeech', 'e.g. Noun')}
|
||||||
{field('English word', 'englishGloss', 'e.g. grace')}
|
{field('English word', 'englishGloss', 'e.g. grace')}
|
||||||
</div>
|
</div>
|
||||||
|
{word.parsing && <GrammarPanel label={word.parsing} />}
|
||||||
{field('Short definition', 'shortDefinition')}
|
{field('Short definition', 'shortDefinition')}
|
||||||
{word.definitionHtml && (
|
{word.definitionHtml && (
|
||||||
<details className="rounded-lg border border-[var(--r-line)] p-2.5">
|
<details className="rounded-lg border border-[var(--r-line)] p-2.5">
|
||||||
@@ -76,8 +83,10 @@ function WordCard({ word, open, onToggle, chunkId, wordStudy }) {
|
|||||||
|
|
||||||
export default function WordStudyTab({ wordStudy, chunk, openWordId, setOpenWordId }) {
|
export default function WordStudyTab({ wordStudy, chunk, openWordId, setOpenWordId }) {
|
||||||
const { suggestingGreekForChunkId, suggestingHebrewForChunkId, suggestGreekWordsForChunk, suggestHebrewWordsForChunk, addGreekWord } = wordStudy;
|
const { suggestingGreekForChunkId, suggestingHebrewForChunkId, suggestGreekWordsForChunk, suggestHebrewWordsForChunk, addGreekWord } = wordStudy;
|
||||||
|
const iq = useIqAvailable(); // the dictionaries come from IQ Bible, so they appear only when the server has a key
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
{iq && <IqDictionary />}
|
||||||
<div className="mb-3 flex flex-wrap gap-2">
|
<div className="mb-3 flex flex-wrap gap-2">
|
||||||
<button type="button" onClick={() => suggestGreekWordsForChunk(chunk.id)} disabled={suggestingGreekForChunkId === chunk.id} className={btn}
|
<button type="button" onClick={() => suggestGreekWordsForChunk(chunk.id)} disabled={suggestingGreekForChunkId === chunk.id} className={btn}
|
||||||
title="Auto-populate Greek words that actually appear in this passage">{suggestingGreekForChunkId === chunk.id ? 'Suggesting…' : 'Suggest Greek'}</button>
|
title="Auto-populate Greek words that actually appear in this passage">{suggestingGreekForChunkId === chunk.id ? 'Suggesting…' : 'Suggest Greek'}</button>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useToast } from '../components/Toasts.jsx';
|
|||||||
import { bookOptions, CHAPTER_COUNTS, makeId, parseBibleChapter } from '../utils/bibleUtils.jsx';
|
import { bookOptions, CHAPTER_COUNTS, makeId, parseBibleChapter } from '../utils/bibleUtils.jsx';
|
||||||
import { fetchStudyChapter } from '../utils/studyText.js';
|
import { fetchStudyChapter } from '../utils/studyText.js';
|
||||||
import { templateStarter } from '../utils/studyTemplates.js';
|
import { templateStarter } from '../utils/studyTemplates.js';
|
||||||
|
import { rememberTemplate } from '../utils/planStudy.js';
|
||||||
|
|
||||||
// Every chunk starts blank; all note fields exist from the start.
|
// Every chunk starts blank; all note fields exist from the start.
|
||||||
export function blankChunk(start, end, spilloverEndVerse = null, templateId = 'blank') {
|
export function blankChunk(start, end, spilloverEndVerse = null, templateId = 'blank') {
|
||||||
@@ -192,7 +193,8 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
|
|||||||
setShowAddChapterForm(false);
|
setShowAddChapterForm(false);
|
||||||
setErrorMessage('');
|
setErrorMessage('');
|
||||||
} else {
|
} else {
|
||||||
// Creating a brand-new project
|
// Creating a brand-new project; remember the template so studies started from a reading plan can use it too
|
||||||
|
rememberTemplate(setup.template);
|
||||||
const newProject = {
|
const newProject = {
|
||||||
id: makeId(),
|
id: makeId(),
|
||||||
title: setup.title,
|
title: setup.title,
|
||||||
|
|||||||
+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];
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
// added on your phone appears on your computer. Signing out clears this device's copy.
|
// added on your phone appears on your computer. Signing out clears this device's copy.
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { getRemotePrayers, saveRemotePrayers } from '../syncService.js';
|
import { getRemotePrayers, saveRemotePrayers } from '../syncService.js';
|
||||||
import { markAnswered, markDeleted, mergePrayers, newPrayer, prayersEqual, reopen, touch, togglePrayedToday } from '../utils/prayers.js';
|
import { markAnswered, markDeleted, mergePrayers, movePinned, newPrayer, prayersEqual, reopen, setPinned, setStatus, touch, togglePrayedToday } from '../utils/prayers.js';
|
||||||
import { localDateString } from '../utils/readingPlans.js';
|
import { localDateString } from '../utils/readingPlans.js';
|
||||||
|
|
||||||
const KEY = 'prayers';
|
const KEY = 'prayers';
|
||||||
@@ -42,6 +42,9 @@ export function usePrayers() {
|
|||||||
const togglePrayed = (id) => replace(id, (p) => togglePrayedToday(p, localDateString()));
|
const togglePrayed = (id) => replace(id, (p) => togglePrayedToday(p, localDateString()));
|
||||||
const answerPrayer = (id, note) => replace(id, (p) => markAnswered(p, note));
|
const answerPrayer = (id, note) => replace(id, (p) => markAnswered(p, note));
|
||||||
const reopenPrayer = (id) => replace(id, (p) => reopen(p));
|
const reopenPrayer = (id) => replace(id, (p) => reopen(p));
|
||||||
|
const pinPrayer = (id, on) => set(setPinned(ref.current, id, on));
|
||||||
|
const movePrayer = (id, dir) => set(movePinned(ref.current, id, dir));
|
||||||
|
const setPrayerStatus = (id, status, note) => replace(id, (p) => setStatus(p, status, note));
|
||||||
// Deleting leaves a marker so the other devices drop it too; the returned function puts it back
|
// Deleting leaves a marker so the other devices drop it too; the returned function puts it back
|
||||||
const deletePrayer = (id) => {
|
const deletePrayer = (id) => {
|
||||||
const before = ref.current.find((p) => p.id === id);
|
const before = ref.current.find((p) => p.id === id);
|
||||||
@@ -70,5 +73,5 @@ export function usePrayers() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => () => window.clearTimeout(timerRef.current), []);
|
useEffect(() => () => window.clearTimeout(timerRef.current), []);
|
||||||
return { prayers, addPrayer, editPrayer, togglePrayed, answerPrayer, reopenPrayer, deletePrayer, importPrayers, pullPrayers: pull, stopPrayers: stop };
|
return { prayers, addPrayer, editPrayer, togglePrayed, answerPrayer, reopenPrayer, pinPrayer, movePrayer, setPrayerStatus, deletePrayer, importPrayers, pullPrayers: pull, stopPrayers: stop };
|
||||||
}
|
}
|
||||||
@@ -200,17 +200,16 @@ export function useProjectLibrary({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Puts a study in a series ("Romans", "Advent 2026"), or takes it out (an empty name).
|
// Changes one top-level field of a stored study (from Home, without opening it) and syncs it.
|
||||||
const setProjectSeries = (id, series) => {
|
const patchStoredStudy = (id, patch) => {
|
||||||
const loaded = loadProjectById(id);
|
const loaded = loadProjectById(id);
|
||||||
if (!loaded) return;
|
if (!loaded) return;
|
||||||
const name = String(series ?? '').trim().slice(0, 80);
|
if (Object.entries(patch).every(([k, v]) => (loaded[k] ?? (typeof v === 'boolean' ? false : '')) === v)) return;
|
||||||
if ((loaded.series ?? '') === name) return;
|
const updated = { ...loaded, ...patch, lastEdited: Date.now() };
|
||||||
const updated = { ...loaded, series: name, lastEdited: Date.now() };
|
|
||||||
saveProjectToStorage(updated);
|
saveProjectToStorage(updated);
|
||||||
unsyncedStudies.add(id);
|
unsyncedStudies.add(id);
|
||||||
setProjectIndex(loadProjectIndex());
|
setProjectIndex(loadProjectIndex());
|
||||||
if (project?.id === id) setProject((current) => (current ? { ...current, series: name } : current));
|
if (project?.id === id) setProject((current) => (current ? { ...current, ...patch } : current));
|
||||||
saveRemoteProject(updated, { baseEdited: getSyncBase(id) }).then((result) => {
|
saveRemoteProject(updated, { baseEdited: getSyncBase(id) }).then((result) => {
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
const serverEdited = result.data?.lastEdited ?? updated.lastEdited;
|
const serverEdited = result.data?.lastEdited ?? updated.lastEdited;
|
||||||
@@ -221,6 +220,43 @@ export function useProjectLibrary({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Puts a study in a series ("Romans", "Advent 2026"), or takes it out (an empty name).
|
||||||
|
const setProjectSeries = (id, series) => patchStoredStudy(id, { series: String(series ?? '').trim().slice(0, 80) });
|
||||||
|
|
||||||
|
// Moves a study to (or back from) the "Finished & archived" section on Home.
|
||||||
|
const setProjectArchived = (id, archived) => patchStoredStudy(id, { archived: !!archived });
|
||||||
|
|
||||||
|
// Changes one chunk of a stored study (from Home, without opening it) and syncs it. `change` takes the chunk, returns the new one.
|
||||||
|
const updateStoredChunk = (id, chunkId, change) => {
|
||||||
|
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 ? change(c) : 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' });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// A weekly recall (what you remembered, and how it went) on one chunk.
|
||||||
|
const saveChunkRecall = (id, chunkId, recall) => updateStoredChunk(id, chunkId, (c) => ({ ...c, recalls: [...(c.recalls ?? []), recall] }));
|
||||||
|
// Notes written from Home (the daily five minutes): counts as editing the chunk.
|
||||||
|
const saveChunkPatch = (id, chunkId, patch) => updateStoredChunk(id, chunkId, (c) => ({ ...c, ...patch, editedAt: Date.now() }));
|
||||||
|
|
||||||
// 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 +323,9 @@ export function useProjectLibrary({
|
|||||||
openStudyChunk,
|
openStudyChunk,
|
||||||
renameProjectInStorage,
|
renameProjectInStorage,
|
||||||
setProjectSeries,
|
setProjectSeries,
|
||||||
|
setProjectArchived,
|
||||||
|
saveChunkRecall,
|
||||||
|
saveChunkPatch,
|
||||||
deleteProject,
|
deleteProject,
|
||||||
pullLatestFromServer,
|
pullLatestFromServer,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -105,3 +105,17 @@ test('studies only on the server are pulled down', async () => {
|
|||||||
setup();
|
setup();
|
||||||
await waitFor(() => expect(loadProjectById('far')?.title).toBe('From elsewhere'));
|
await waitFor(() => expect(loadProjectById('far')?.title).toBe('From elsewhere'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('archiving a study keeps it, flags it in the index, syncs it, and restoring clears the flag', async () => {
|
||||||
|
saveProjectToStorage(study('s1'));
|
||||||
|
const hook = setup();
|
||||||
|
act(() => hook.result.current.setProjectArchived('s1', true));
|
||||||
|
expect(loadProjectById('s1').archived).toBe(true);
|
||||||
|
expect(loadProjectIndex().find((e) => e.id === 's1').archived).toBe(true);
|
||||||
|
await waitFor(() => expect(sync.saveRemoteProject).toHaveBeenCalledWith(expect.objectContaining({ id: 's1', archived: true }), expect.anything()));
|
||||||
|
sync.saveRemoteProject.mockClear();
|
||||||
|
act(() => hook.result.current.setProjectArchived('s1', true)); // already archived: nothing to do
|
||||||
|
expect(sync.saveRemoteProject).not.toHaveBeenCalled();
|
||||||
|
act(() => hook.result.current.setProjectArchived('s1', false));
|
||||||
|
expect(loadProjectIndex().find((e) => e.id === 's1').archived).toBe(false);
|
||||||
|
});
|
||||||
Loaded 100 of 185 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user