Compare commits

...
Author SHA1 Message Date
nmemmertandClaude Sonnet 5.5 5e80d307a7 Count "Download from my account" as a backup for the Home nudge
Only "Download backup" recorded the date, so downloading from the account left
Home saying "You haven't downloaded a backup yet". Both now record it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 14:21:20 -04:00
nmemmertandClaude Sonnet 5.5 1bc515164a Merge studying-and-housekeeping: daily five minutes, reading list, browser tests
Build and Push Docker Image / build-and-push (push) Successful in 8m10s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 14:13:03 -04:00
nmemmertandClaude Sonnet 5.5 3ee1535c87 Real-browser tests (Playwright) and housekeeping
- npm run test:e2e builds the app, runs it on port 3101 with an empty throwaway
  database and drives Chrome (the installed one; no browser download) through
  notes and the paste check, the daily five minutes and weekly recall,
  translation compare (Bible text faked), and two-screen presenting.
- Vitest skips e2e/; ESLint knows the e2e folder and playwright.config.js.
- .gitignore: the local demo video, Playwright output and sign-in file.
- README and TODO describe the browser tests and what CI still needs.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 14:00:15 -04:00
nmemmertandClaude Sonnet 5.5 eccc5e509d Studying: daily five minutes, reading list, plan-day studies use your template
- Home: "Your five minutes": a rotating Observe/Interpret/Apply prompt on the
  passage whose matching note is thinnest in your own words, a 5:00 clock and a
  box that appends to that note. Pasting is blocked; sessions show in Insights.
- Study: a Reading list tab (R) for the study: books and articles to consult,
  with a note and a tick; merges across devices.
- Starting a study from a reading-plan day now uses the notes template you last
  picked when starting a study by hand (logic moved to utils/planStudy.js).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 13:51:49 -04:00
nmemmertandClaude Sonnet 5.5 0781fa3476 Merge more-study-features: Insights, compare, Finish stage, presenter view and more
Build and Push Docker Image / build-and-push (push) Successful in 5m28s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 13:25:15 -04:00
nmemmertandClaude Sonnet 5.5 7ba5ed41b5 Present mode: private speaker notes, audience window, presenter view
- Speaker notes are hidden on the presenting screen by default (N / "Show notes"
  turns them on for one-screen use), so a mirrored display never shows them.
- "Audience window" opens a second window for a projector (?audience=1, no sign-in)
  that shows only the point, its passage and quoted verse. Notes, minutes, the
  script and the timer are never sent to it.
- With it connected this screen becomes a presenter view: what the audience sees,
  your notes, the next point, plus Blank audience (B) and audience text size.
- Script mode sends the audience only the title.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 13:23:16 -04:00
nmemmertandClaude Sonnet 5.5 0f1a181d87 Rename the Export stage to Finish
It now holds files, sharing, handouts, slides, importing notes, schedule,
co-teachers and earlier versions, not just exports.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 13:17:08 -04:00
nmemmertandClaude Sonnet 5.5 2c7c7145c4 Teaching: hide-notes-until share, session length check, slides export
- A study share link can hide the owner's notes until a chosen time: the server
  sends only passages and discussion questions until then, with a banner in the
  shared view (new share_reveal column, migrated on start).
- Outline: a session length with "N of M min planned, X min over" and a marker
  on the point where the plan runs past it.
- Export > Slides: printable 16:9 slides (title, passage, outline points with
  their verse, discussion questions) for one session or all; light or dark.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 13:08:21 -04:00
nmemmertandClaude Sonnet 5.5 67645cf1df App quality: offline badge, per-note history, keyboard study mode, notes import
- Study cards say whether the Bible text works offline, with a button to save
  the missing book (notes are always kept on the device).
- Each note keeps its last 12 versions with Restore (History under each note).
- Keyboard-only study: 1/2/3 write in Observe/Interpret/Apply, C/W/X/P switch
  resource tabs, V compares, Esc leaves a note box; listed under "?".
- Export > Import notes: bring Word/Markdown/text notes into the matching
  chunks without overwriting anything; can mark them as not your own.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 13:01:11 -04:00
nmemmertandClaude Sonnet 5.5 648c3206df Highlight translation differences, first-draft snapshot, verse notes, paste check
- Compare translations can highlight the words that differ from the first
  translation (toggle remembered).
- A copy of your notes is kept the first time commentary is shown; "First draft,
  before commentary" under More shows what changed since. Saving it doesn't
  count as editing the chunk.
- Notes on single verses: a pencil next to each verse in Study, and a note line
  under each verse in Compare. They merge per verse across devices.
- Pasting 15+ words into a note asks "Is this from AI?"; "I wrote it" counts the
  words as yours again.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 12:56:57 -04:00
nmemmertandClaude Sonnet 5.5 3a28ffa51a Add Observe/Interpret/Apply boxes to the translation compare view
The same notes as the Study page, editable beside the translations; long
pastes are tagged as pasted the same way.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 12:50:26 -04:00
nmemmertandClaude Sonnet 5.5 76df145b1e Fold the Study notes/resources halves; compare translations
- Your notes and Commentary & resources can each be folded so the other
  fills the right column (remembered on this device; never both folded).
- Compare has a Translations view: pick BSB/ESV/NET/NLT/CSB/NASB and read the
  chunk verse by verse, with each translation's notice.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 12:48:38 -04:00
nmemmertandClaude Sonnet 5.5 d8e522dd06 Add Insights, Observe-first, compare, handout layouts, richer search, backup nudge
- Commentary and study aids wait for 15 words of your own Observe notes.
- Insights page: this week's own work, themes across studies, words you keep
  studying, what slipped in recalls, open follow-ups.
- Recall can record what slipped; session notes (teach it back, who came,
  group notes, follow-ups); "Pray about this" from Apply.
- Compare any two passages side by side.
- Handout layouts: standard, questions only, large print, note-taking.
- Search understands "in <book>", "last week/month/year" and a year.
- Home shows the last backup age and nudges after two weeks.
- Phone: the stage bar scrolls instead of clipping Script and Export.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 12:42:11 -04:00
nmemmertandClaude Sonnet 5.5 2ddc4e1ff6 Merge own-work-and-recall: own-work tracking, Claude check, weekly recall
Build and Push Docker Image / build-and-push (push) Successful in 6m18s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 12:26:23 -04:00
nmemmertandClaude Sonnet 5.5 0c8a632cff Add own-work tracking, Claude hand-off check, and weekly recall
- Long pastes into Observe/Interpret/Apply are tagged as pasted; notes show
  Mine / N pasted and an own-words share. Pastes follow text through merges.
- The "-> Claude" buttons on the Script stage ask first when a chunk lacks
  enough of the user's own (non-pasted) words.
- Weekly recall card on Home: write a passage from memory, compare with notes,
  self-rate; recalls are saved on the chunk and synced.
- Recall history on Home with an eight-week strip and the full list.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 12:25:49 -04:00
nmemmertandClaude Sonnet 5.5 edc5a65d2b Keep the verse of the day when IQ Bible is down
Build and Push Docker Image / build-and-push (push) Successful in 5m42s
- Fall back to a built-in verse (one a day, same for everyone) in the reader's own translation
- No translation picker in fallback mode; card shows even with no IQ Bible key

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 10:30:04 -04:00
nmemmertandClaude Sonnet 5.5 512c6b6889 Put every preference in Settings; same header on every page
Build and Push Docker Image / build-and-push (push) Successful in 5m7s
- Settings > Preferences: typeface, line spacing, verse numbers, highlight colour names,
  My Studies grid/list and sort, verse-of-the-day Bible and picture, commentary sources, study templates
- Header uses one fixed width and reserves scrollbar space so the nav sits in the same place on every page

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:10:12 -04:00
nmemmertandClaude Sonnet 5.5 a2185ba1dd Easier navigation on iPhone
Build and Push Docker Image / build-and-push (push) Successful in 5m7s
- Phones get a bottom tab bar (Studies, Bible, Plans, Prayer, Account); wider screens keep the header nav
- The Account menu opens upward from the bar; toasts sit above it
- Reader and study pages get a labelled Home button instead of a small arrow

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 13:54:24 -04:00
nmemmertandClaude Sonnet 5.5 9893883b19 Split server/index.js into one file per area
Build and Push Docker Image / build-and-push (push) Successful in 5m37s
Routes move to server/routes/ (auth, account, projects, reader, sharing, admin);
index.js keeps setup only. A smoke test walks 90 API calls against the real
server and compares every status with the pre-split behaviour.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 13:25:52 -04:00
nmemmertandClaude Sonnet 5.5 31ac6b9179 Make fewer IQ Bible calls
- Load the narrator list only when the audio dropdown is opened
- Keep today's verse and devotional in memory for an hour (not saved)
- Back off for a minute after 'allowance used up' or unreachable; keep 'nothing here' answers
- Server shares one upstream call between identical simultaneous requests (nothing stored)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 13:25:51 -04:00
125 changed files with 5059 additions and 1210 deletions

No files matched your search

+8
View File
@@ -13,3 +13,11 @@ coverage/
.env
.session-secret
server/data/backups/
# Playwright
e2e/.auth.json
test-results/
playwright-report/
# a local demo video, not part of the app
demo/
+8 -1
View File
@@ -151,10 +151,17 @@ Private / personal use.
## Development
```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)
```
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.
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).
+4 -3
View File
@@ -9,9 +9,10 @@ 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.
- [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`).
- [ ] **Checks on every push.** Run tests and lint on the Gitea server, plus a few real-browser tests (Playwright) for sign-in,
creating a study and syncing; so far everything is tested in simulation.
- [ ] **Split `server/index.js`** (now about 1,100 lines) into one file per area (auth, projects, sharing, reader data, admin).
- [ ] **Checks on every push.** Run `npm run test:run`, `npm run lint` and `npm run test:e2e` on the Gitea server for every push.
The real-browser tests now exist (`e2e/`, run by hand); what's left is running them automatically, and adding sign-in
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
- [ ] **Comments between co-teachers.** Notes on a chunk addressed to the other person, instead of editing each other's text.
+12
View File
@@ -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' });
});
+37
View File
@@ -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/);
});
+51
View File
@@ -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);
}
+34
View File
@@ -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();
});
+36
View File
@@ -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();
});
+9
View File
@@ -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
+43
View File
@@ -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
View File
@@ -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 } },
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/**'],
languageOptions: { globals: { ...globals.browser, ...globals.node, vi: 'readonly' } },
+46
View File
@@ -27,6 +27,7 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.64.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
@@ -1635,6 +1636,22 @@
"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": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz",
@@ -6190,6 +6207,35 @@
"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": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
+3 -1
View File
@@ -15,7 +15,8 @@
"coverage": "vitest run --coverage",
"backup": "bash scripts/backup-data.sh",
"restore": "bash scripts/restore-data.sh",
"lint": "eslint ."
"lint": "eslint .",
"test:e2e": "playwright test"
},
"dependencies": {
"@simplewebauthn/browser": "^13.3.0",
@@ -37,6 +38,7 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.64.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
+18
View File
@@ -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' } },
],
});
+14 -12
View File
@@ -140,7 +140,7 @@ export function initDb() {
db.exec('ALTER TABLE projects ADD COLUMN outline_share_token TEXT');
}
// 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}`);
}
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. */
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 ----
@@ -800,14 +800,14 @@ export function clearOutlineShareToken(id, userId) {
// ---- expiry and passcode for either kind of read-only link (outline = the outline-only link) ----
const shareCols = (outline) => (outline
? { token: 'outline_share_token', expires: 'outline_share_expires', pass: 'outline_share_pass' }
: { token: 'share_token', expires: 'share_expires', pass: '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', 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) {
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);
return row ? { token: row.token ?? null, expiresAt: row.expiresAt ?? null, hasPasscode: !!row.pass } : null;
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, revealAt: row.revealAt ?? null } : null;
}
/** 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;
}
/** Sets expiry (ms timestamp or null) and passcode hash (or null) on an existing link. */
export function setShareSettings(id, userId, { expiresAt, passHash }, outline = false) {
/** 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, revealAt }, outline = false) {
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. */
export function getSharedByToken(token, outline = false) {
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;
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). */
+22 -1039
View File
File diff suppressed because it is too large. Load diff
+21 -4
View File
@@ -89,6 +89,24 @@ async function cacheTtl(headers, isText) {
const cache = new Map(); // key -> { at, status, type, body }
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 }
function overLimit(userId, heavy) {
const bucket = heavy ? 'h' : 'n';
@@ -119,12 +137,11 @@ export function registerIqBibleRoutes(app, requireAuth) {
return;
}
try {
const upstream = await fetch(`${BASE()}${target.key}`, { headers, signal: AbortSignal.timeout(20_000) });
const body = Buffer.from(await upstream.arrayBuffer());
const type = upstream.headers.get('content-type') || 'application/json';
const upstream = await fetchOnce(target.key, headers);
const { body, type } = upstream;
if (upstream.status === 401) return res.status(502).json({ error: 'The IQ Bible key was rejected. Check IQBIBLE_API_KEY.' });
if (upstream.status === 429) {
const retry = upstream.headers.get('retry-after');
const retry = upstream.retryAfter;
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.' });
}
+28 -1
View File
@@ -1,5 +1,5 @@
// @vitest-environment node
import { test, expect } from 'vitest';
import { test, expect, vi } from 'vitest';
import { resolveIqPath } from './iqbible.js';
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(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; }
});
+23
View File
@@ -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.' };
+90
View File
@@ -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
+167
View File
@@ -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);
+133
View File
@@ -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 });
}
});
}
+153
View File
@@ -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.' });
}
});
}
+324
View File
@@ -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.' });
}
});
}
+104
View File
@@ -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.' });
}
});
}
+154
View File
@@ -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.' });
}
});
}
+152
View File
@@ -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.' });
}
});
}
+24
View File
@@ -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.' };
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);
}
+33
View File
@@ -31,3 +31,36 @@ test('an expired link is reported as expired even before the passcode is checked
const passHash = await hashPasscode('x');
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);
});
+16
View File
@@ -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,
};
}
+18 -24
View File
@@ -11,6 +11,8 @@ import Tooltips from './components/Tooltips.jsx';
import { useAudio } from './hooks/useAudio.js';
import { useAuth } from './hooks/useAuth.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 { useHistoryNav } from './hooks/useHistoryNav.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 SettingsPage = lazy(() => import('./pages/SettingsPage.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 OutlinePage = lazy(() => import('./pages/OutlinePage.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")
useEffect(() => {
const names = {
home: 'My Studies', setup: 'Chunks', study: 'Study', outline: 'Outline', script: 'Script', export: 'Export',
import: 'Import', pronunciation: 'Pronunciation Guide', settings: 'Settings', admin: 'Admin', 'reading-plans': 'Reading Plans', prayer: 'Prayer journal',
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', insights: 'Insights',
};
let page = names[currentPage] ?? '';
if (currentPage === 'reader') {
@@ -355,12 +359,13 @@ const AppInner = () => {
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) => ({
...current,
chapters: current.chapters.map((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) => {
setStudyBuildBusy(true);
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 data = await fetchStudyChapter('BSB', book, chapter);
const verses = parseBibleChapter(data);
const bookName = bookOptions.find((b) => b.abbrev === book)?.name ?? book;
const chunk = {
id: makeId(),
startVerse: verses[0]?.number ?? 1,
endVerse: verses.at(-1)?.number ?? 1,
spilloverEndVerse: null,
observation: '', interpretation: '', application: '',
crossReferences: [], greekWords: [], generalNotes: '',
episodeNumber: '', episodeTitle: '', finalScript: '', tags: [],
};
return { book: bookName, bookAbbrev: book, chapter: String(chapter), verses, chunks: [chunk] };
return { bookAbbrev: book, bookName: bookOptions.find((b) => b.abbrev === book)?.name ?? book, chapter, verses: parseBibleChapter(data) };
}));
const newProject = {
id: makeId(),
title: title || 'Reading Plan Study',
translation: 'BSB',
lastEdited: Date.now(),
selectedChunkId: chapters[0]?.chunks[0]?.id ?? null,
chapters,
};
const newProject = planStudyProject({ chapters, title, templateId: lastTemplate, templateDef, makeId });
setProject(newProject);
setCurrentPage('study');
} catch (err) {
@@ -584,6 +573,9 @@ function AppRouter() {
// A read-only outline link: no sign-in, nothing editable
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 (needsPasscode) return <SharedPasscodeForm dark error={passcodeError} busy={checking} onSubmit={submitPasscode} />;
if (sharedError) {
@@ -606,6 +598,7 @@ function AppRouter() {
return (
<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">
{sharedProject.hiddenUntil ? `Notes appear ${new Date(sharedProject.hiddenUntil).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })}. For now: the passages and questions. ` : ''}
🔗 Read-only shared view —{' '}
<a href="/" className="underline hover:text-white">go to Bible Study Project</a>
</div>
@@ -629,6 +622,7 @@ function AppRouter() {
if (currentPage === 'prayer') return <PrayerPage />;
if (currentPage === 'reader') return <ReaderPage />;
if (currentPage === 'import') return <ImportPage />;
if (currentPage === 'insights') return <InsightsPage />;
if (currentPage === 'study') return <StudyPage />;
if (currentPage === 'outline') return <OutlinePage />;
if (currentPage === 'script') return <ScriptPage />;
+56
View File
@@ -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 -1
View File
@@ -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
// sync through the account; if you both change the same study, "Combine both" keeps everything.
import { useCallback, useEffect, useState } from 'react';
+66
View File
@@ -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>
);
}
+46
View File
@@ -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);
});
+9 -3
View File
@@ -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 { printHandout, sessionsFor } from '../utils/handout.js';
import { HANDOUT_LAYOUTS, printHandout, sessionsFor } from '../utils/handout.js';
export default function HandoutExport({ project }) {
const sessions = sessionsFor(project);
const [which, setWhich] = useState('all');
const [lines, setLines] = useState(true);
const [layout, setLayout] = useState('standard');
const [blocked, setBlocked] = useState(false);
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 (
<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>
@@ -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>)}
</select>
</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>
<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>
+61
View File
@@ -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>
);
}
+19 -5
View File
@@ -11,21 +11,27 @@ export const expiryText = (expiresAt, now = Date.now()) => {
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 [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 options = {};
if (days !== 'keep') options.expiresInDays = days === '' ? null : Number(days);
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 (
<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">
<label className="text-xs text-[var(--r-sub)]">Stop working
<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)'}
<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>
{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>
{hasPasscode && <button type="button" onClick={() => onSave({ passcode: null })} disabled={busy} className="text-xs text-[var(--r-danger,#b42318)] hover:underline">Remove passcode</button>}
</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>
);
}
+21
View File
@@ -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>
);
}
+100 -9
View File
@@ -1,8 +1,15 @@
// 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
// 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 { 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 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 [index, setIndex] = useState(0);
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 step = steps[index];
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
useEffect(() => {
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); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp' || e.key === 'PageUp') { e.preventDefault(); go(-1); }
} 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 === '-') bigger(-1);
};
window.addEventListener('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 fontSize = `${SIZES[size]}px`;
const total = steps.length ? steps[steps.length - 1].plannedEnd : 0;
@@ -102,29 +149,73 @@ export default function PresentMode({ title, subtitle = '', mode, script = '', t
</label>
</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>
</div>
{mode === 'script' ? (
<>
{banner}
<div ref={scroller} className="min-h-0 flex-1 overflow-y-auto px-[6vw] py-[8dvh]" style={{ scrollBehavior: 'auto' }}>
{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>
: <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 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]">
{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>
<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>}
{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>
<>
<StepFace step={step} index={index} total={steps.length} fontSize={fontSize} minutes={step.minutes} note={showNotes ? step.note : ''} />
{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>}
</>
) : <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 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>
<span className="text-xs opacity-60">{total ? `${total} min planned` : 'Set minutes on points to see your pace'} · arrows or space to move</span>
+78 -3
View File
@@ -1,9 +1,10 @@
import { render, screen, act } from '@testing-library/react';
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';
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 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();
render(<PresentMode mode="outline" title="Romans" tree={tree} onClose={() => {}} />);
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.getByRole('button', { name: /back/i })).toBeDisabled();
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' }));
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' });
});
+47
View File
@@ -0,0 +1,47 @@
// Home: how your weekly recalls have gone — the last eight weeks at a glance, then every recall you've written.
import { useMemo, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { loadProjectById } from '../utils/projectStorage.js';
import { recallHistory, recallSummary } from '../utils/recall.js';
const LABEL = { solid: 'Had it', partly: 'Partly', forgot: 'Slipped' };
const DOT = { solid: 'bg-[var(--r-accent)] border-[var(--r-accent)]', partly: 'bg-[var(--r-hl)] border-[var(--r-accent)]', forgot: 'border-[var(--r-sub)]' };
export default function RecallHistory() {
const { projectIndex, openStudyChunk } = useApp();
const [open, setOpen] = useState(false);
const history = useMemo(() => recallHistory(projectIndex.map((e) => loadProjectById(e.id)).filter(Boolean)), [projectIndex]);
if (history.length === 0) return null;
const { counts, total, recent } = recallSummary(history);
return (
<section aria-label="Recall history" className="mb-4 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-3 text-sm">
<button type="button" onClick={() => setOpen((o) => !o)} aria-expanded={open} className="flex w-full flex-wrap items-center justify-between gap-3 text-left">
<span><span className="font-medium">Recall history</span> <span className="text-xs text-[var(--r-sub)]">{total} recall{total === 1 ? '' : 's'} · {counts.solid} had it · {counts.partly} partly · {counts.forgot} slipped</span></span>
<span className="flex items-center gap-1" aria-label="Last eight weeks">
{recent.map((w) => (
<span key={w.start} title={w.rating ? LABEL[w.rating] : 'No recall that week'}
className={`h-3 w-3 rounded-full border ${w.rating ? DOT[w.rating] : 'border-[var(--r-line)]'}`} />
))}
<span className="ml-1 text-xs text-[var(--r-sub)]">{open ? '▾' : '▸'}</span>
</span>
</button>
{open && (
<ul className="mt-3 space-y-2.5">
{history.map((h) => (
<li key={`${h.chunkId}-${h.at}`} className="rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] p-3">
<div className="flex flex-wrap items-center justify-between gap-2 text-xs">
<span><span className="font-medium text-sm">{h.reference}</span> <span className="text-[var(--r-sub)]">· {h.projectTitle} · {new Date(h.at).toLocaleDateString()}</span></span>
<span className="flex items-center gap-2">
<span className="rounded-full border border-[var(--r-line)] px-2 py-0.5">{LABEL[h.rating] ?? '—'}</span>
<button type="button" onClick={() => openStudyChunk(h.projectId, h.chunkId)} className="text-[var(--r-accent)] hover:underline">Open</button>
</span>
</div>
{h.text && <p className="mt-1.5 line-clamp-3 whitespace-pre-line font-serif text-[13px] leading-relaxed text-[var(--r-sub)]">{h.text}</p>}
</li>
))}
</ul>
)}
</section>
);
}
+1 -1
View File
@@ -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 { formatChunkReference } from '../utils/bibleUtils.jsx';
import { buildIcs, downloadIcs, sessionDates } from '../utils/icsFile.js';
+4
View File
@@ -22,6 +22,10 @@ const BY_PAGE = {
],
study: [
['← →', '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'],
],
};
+30
View File
@@ -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>
);
}
+15
View File
@@ -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>
);
}
+1 -1
View File
@@ -43,7 +43,7 @@ export function ToastProvider({ children }) {
return (
<ToastContext.Provider value={value}>
{children}
<div style={themeVars(theme)} className="pointer-events-none fixed inset-x-0 bottom-0 z-[100] flex flex-col items-center gap-2 px-4 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] font-sans">
<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) => (
<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)]'}`}>
+1 -1
View File
@@ -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.
import { useCallback, useEffect, useState } from 'react';
import { getStudyVersion, listStudyVersions } from '../syncService.js';
+82
View File
@@ -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>
);
}
+17 -12
View File
@@ -1,26 +1,30 @@
// Home card: today's verse and Spurgeon's Morning & Evening reading, both from IQ Bible.
import { useEffect, useState } from 'react';
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 { SCENES, loadPhotoList, resolveBackground, sceneDataUrl } from '../../utils/verseBackgrounds.js';
import { bookOptions } from '../../utils/bibleUtils.jsx';
import { READER_TRANSLATIONS, fetchChapter } from '../../utils/bibleText.js';
import { fallbackVerseFor } from '../../utils/fallbackVerses.js';
export default function IqDailyCard() {
const on = useIqAvailable();
if (!on) return null;
return <Card />;
const status = useIqStatus();
if (status === null) return null;
return <Card iq={status} />;
}
function Card() {
function Card({ iq }) {
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.
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 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 ref = votd.data?.data;
const ref = votd.data?.data ?? (fallback ? fallbackVerseFor() : undefined);
const [text, setText] = useState({ key: '', value: '', error: false });
const [sharing, setSharing] = useState(false);
// The picture behind the verse: a different one each day by default, or the one you chose.
@@ -42,15 +46,16 @@ function Card() {
.catch(() => { if (!controller.signal.aborted) setText({ key, value: '', error: true }); });
return () => controller.abort();
}, [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 verse = votd.data?.data;
const verse = ref;
const reading = dev.data?.data?.[part];
const loaded = text.key === key;
const shown = loaded && text.value ? text.value : loaded && text.error ? ref?.text ?? '' : '…';
const shownTranslation = loaded && text.error ? 'KJV' : translation;
const name = (abbrev) => bookOptions.find((b) => b.abbrev === abbrev)?.name ?? abbrev;
if (!verse && !reading) return null;
if (fallback && !reading && loaded && text.error) return null; // nothing to show, not even the built-in verse
return (
<section className="mb-4 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Daily verse and devotional">
{verse && (
@@ -65,10 +70,10 @@ function Card() {
<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>}
</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">
{READER_TRANSLATIONS.map((t) => <option key={t.id} value={t.id}>{t.id}</option>)}
</select>
</select>}
</span>
</div>
{/* the translation's text; the KJV from IQ Bible is the fallback if that Bible can't be loaded right now */}
+11 -2
View File
@@ -1,11 +1,13 @@
// <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.
import { useState } from 'react';
import { useIq, useIqAvailable } from '../../hooks/useIq.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 { data } = useIq(on ? 'audio' : null);
const { data } = useIq(on && enabled ? 'audio' : null);
const sets = listOf(data).filter((s) => s.active !== false);
if (!on || sets.length === 0) return null;
return (
@@ -14,3 +16,10 @@ export default function IqNarratorOptions() {
</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 } };
}
+14
View File
@@ -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', () => {
const chunk = { id: 'c1', startVerse: 14, endVerse: 16, tags: ['love'], generalNotes: '' };
const chapter = { book: 'John', bookAbbrev: 'JHN', chapter: '3' };
+11
View File
@@ -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>
);
}
+2 -1
View File
@@ -1,6 +1,7 @@
// Top and bottom chrome for the reader. Everything stays one tap away; focus
// mode hides both.
import { ChevronLeft, ChevronRight, ChevronDown, SearchIcon, LinkIcon, PenIcon, HeadphonesIcon, FocusIcon, CheckIcon, CompareIcon, HighlighterIcon } from './icons.jsx';
import HomeButton from './HomeButton.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';
@@ -9,7 +10,7 @@ export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks,
return (
<div className="safe-top sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)] font-sans">
<div className="mx-auto flex h-12 max-w-4xl items-center gap-1 px-2">
<button type="button" onClick={onBack} className="flex h-9 items-center gap-0.5 rounded-lg pl-1 pr-2 text-[var(--r-sub)] transition hover:bg-black/5" aria-label="Back to home" data-tip="Back to home"><ChevronLeft /><span className="hidden text-xs sm:inline">Home</span></button>
<HomeButton onClick={onBack} />
<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)">
<span className="truncate">{title}</span><ChevronDown />
+1
View File
@@ -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 };
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 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>;
+3 -2
View File
@@ -4,7 +4,7 @@ import { useApp } from '../../context/AppContext.js';
import { useToast } from '../Toasts.jsx';
import { DownloadProgress } from '../OfflineSave.jsx';
import { useOfflineSave } from '../../hooks/useOfflineSave.js';
import { backupFileName, buildBackup, restoreProjects, validateBackup } from '../../utils/backup.js';
import { backupFileName, buildBackup, markBackedUp, restoreProjects, validateBackup } from '../../utils/backup.js';
import { saveRemoteProject } from '../../syncService.js';
const btn = 'rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5 disabled:opacity-50';
@@ -38,6 +38,7 @@ export default function DataSection() {
link.click();
link.remove();
URL.revokeObjectURL(url);
markBackedUp();
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">
<button type="button" onClick={download} className={btn}>Download backup</button>
{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>
<input ref={fileRef} type="file" accept="application/json,.json" onChange={choose} className="hidden" aria-label="Backup file" />
@@ -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);
});
+121
View File
@@ -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>
);
}
+214
View File
@@ -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>
);
}
+36
View File
@@ -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>
);
}
+82 -6
View File
@@ -1,7 +1,12 @@
// Observe / Interpret / Apply as an accordion (one open at a time), plus a "More"
// area for background notes and tags so the page stays quiet.
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import SessionNotes from './SessionNotes.jsx';
import FirstDraft from './FirstDraft.jsx';
import { usePasteCheck } from './PastePrompt.jsx';
import { listNoteVersions, recordNoteVersion } from '../../utils/noteHistory.js';
import DictateButton, { appendSpoken } from '../DictateButton.jsx';
import { ownShare, ownWords, pastedWords, clearPastes } from '../../utils/ownWork.js';
import { templateFromChunk, templatePrompt } from '../../utils/studyTemplates.js';
const SECTIONS = [
@@ -16,11 +21,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 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 }) {
const [open, setOpen] = useState(() => {
try { return localStorage.getItem('studyNotesOpen') || 'observation'; } catch { return 'observation'; }
});
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 [tplOpen, setTplOpen] = useState(false);
const [tagInput, setTagInput] = useState('');
@@ -36,11 +55,39 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
};
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 (
<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 }) => {
const isOpen = open === field;
const n = words(chunk[field]);
const pasted = pastedWords(chunk, field);
return (
<div key={field} className={`overflow-hidden rounded-2xl border ${isOpen ? 'border-[var(--r-accent)]' : 'border-[var(--r-line)]'}`}>
<button type="button" onClick={() => setOpen(isOpen ? null : field)} aria-expanded={isOpen}
@@ -50,13 +97,40 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
<span className="text-sm font-medium">{label}</span>
<span className="text-xs text-[var(--r-sub)]">{hint}</span>
</span>
<span className="text-xs text-[var(--r-sub)]">{n ? `${n} word${n === 1 ? '' : 's'}` : ''}</span>
<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>
{isOpen && (
<div className="px-4 pb-4">
<textarea aria-label={label} value={chunk[field] ?? ''} rows={7} placeholder={templatePrompt(templateId, field) ?? placeholder}
onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })} className={areaCls} />
<div className="mt-1.5"><DictateButton label={`Dictate ${label.toLowerCase()}`} onText={(said) => updateChunk(chunk.id, { [field]: appendSpoken(chunk[field], said) })} /></div>
<textarea ref={(el) => { areas.current[field] = el; }} aria-label={label} value={chunk[field] ?? ''} rows={7} placeholder={templatePrompt(templateId, field) ?? placeholder}
onFocus={() => keepVersion(field)} onBlur={() => keepVersion(field)} onPaste={pasteCheck.onPaste(field)} onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })} className={areaCls} />
{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>
@@ -82,6 +156,8 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
<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}`} />
</label>
<FirstDraft chunk={chunk} />
<SessionNotes chunk={chunk} updateChunk={updateChunk} />
{saveTemplate && (
<div className="rounded-xl border border-dashed border-[var(--r-line)] p-3">
{!tplOpen ? (
+47 -6
View File
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react';
import { useApp } from '../../context/AppContext.js';
import { themeVars, READER_THEMES } from '../reader/readerThemes.js';
import { ChevronLeft, SearchIcon } from '../reader/icons.jsx';
import { useIsPhone } from '../../hooks/useIsPhone.js';
// `short` is what a phone shows; the full label is still the button's accessible name.
const NAV = [
@@ -13,8 +14,18 @@ const NAV = [
{ 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.
function AccountMenu() {
function AccountMenu({ tab = false, active = false }) {
const { authUser, readerTheme, setReaderTheme, setCurrentPage, logout } = useApp();
const [open, setOpen] = useState(false);
const ref = useRef(null);
@@ -33,12 +44,20 @@ function AccountMenu() {
return (
<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}
className="flex h-8 w-8 items-center justify-center rounded-full border border-[var(--r-line)] bg-[var(--r-bg)] text-xs font-medium uppercase hover:bg-black/5">
{authUser?.email?.[0] ?? '·'}
</button>
)}
{open && (
<div role="menu" className="absolute right-0 top-full z-40 mt-2 w-60 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-2 shadow-xl">
<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>}
<div className="px-3 pb-2 pt-1">
<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 }) {
const { readerTheme, goHome, goStudies, openBibleReader, openReadingPlans, openPrayer, openPalette } = useApp();
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 (
<div className="min-h-dvh bg-[var(--r-bg)] font-sans text-[var(--r-text)]" style={themeVars(readerTheme)}>
<a href="#content" className="skip-link">Skip to content</a>
<header className="safe-top sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)]">
<div className={`mx-auto flex h-12 ${maxWidth} items-center gap-2 px-3 sm:gap-4 sm:px-4`}>
<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">
<img src="/logo.svg" alt="" className="h-6 w-6" />
<span className="hidden text-sm font-medium sm:inline">Bible Study</span>
</button>
<nav aria-label="Main" className="flex items-center gap-0.5">
{!phone && <nav aria-label="Main" className="flex items-center gap-0.5">
{NAV.map((n) => (
<button key={n.id} type="button" onClick={go[n.id]} aria-current={active === n.id ? 'page' : undefined} aria-label={n.label}
className={`whitespace-nowrap rounded-full px-2.5 py-1.5 text-sm sm:px-3 ${active === n.id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>
<span className="sm:hidden" aria-hidden="true">{n.short}</span><span className="hidden sm:inline" aria-hidden="true">{n.label}</span>
</button>
))}
</nav>
</nav>}
<div className="ml-auto flex items-center gap-2">
{openPalette && (
<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>
</button>
)}
<AccountMenu />
{!phone && <AccountMenu />}
</div>
</div>
</header>
@@ -103,6 +129,21 @@ export default function PageShell({ title, onBack, actions, active = null, maxWi
</div>
{children}
</div>
{phone && (
<>
<div aria-hidden="true" className="h-[calc(5rem+env(safe-area-inset-bottom,0px))]" />
<nav aria-label="Main" className="fixed inset-x-0 bottom-0 z-30 grid grid-cols-5 border-t border-[var(--r-line)] bg-[var(--r-bar)] pb-[env(safe-area-inset-bottom)]">
{NAV.map((n) => (
<button key={n.id} type="button" onClick={go[n.id]} aria-current={active === n.id ? 'page' : undefined} aria-label={n.label}
className={`flex min-h-[52px] flex-col items-center justify-center gap-0.5 text-[11px] ${active === n.id ? 'text-[var(--r-text)]' : 'text-[var(--r-sub)]'}`}>
<TabIcon id={n.id} />
<span aria-hidden="true" className={active === n.id ? 'font-medium' : ''}>{n.short}</span>
</button>
))}
<AccountMenu tab active={['settings', 'admin'].includes(active)} />
</nav>
</>
)}
</div>
);
}
@@ -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');
});
+42
View File
@@ -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>
);
}
+32
View File
@@ -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>
);
}
+27 -5
View File
@@ -1,26 +1,40 @@
// Right-hand pane of the Study stage: commentary, word study, cross-references and
// people & places, all driven by the selected chunk.
import { useState } from 'react';
import { useEffect, useState } from 'react';
import CommentaryChunkTab from './CommentaryChunkTab.jsx';
import WordStudyTab from './WordStudyTab.jsx';
import CrossRefsTab from './CrossRefsTab.jsx';
import PeopleChunkTab from './PeopleChunkTab.jsx';
import ReadingListTab from './ReadingListTab.jsx';
import IqChunkAids from '../iq/IqChunkAids.jsx';
import { useIqAvailable } from '../../hooks/useIq.js';
import { OBSERVE_FIRST_MIN, observeFirstDone, ownWords } from '../../utils/ownWork.js';
export const RESOURCE_TABS = [
{ id: 'commentary', label: 'Commentary' },
{ id: 'words', label: 'Word study' },
{ id: 'refs', label: 'Cross-refs' },
{ 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
];
export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex, project, updateChunk, notify, resources, wordStudy, crossRefs, openWordId, setOpenWordId }) {
export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex, project, updateChunk, updateProject, notify, resources, wordStudy, crossRefs, openWordId, setOpenWordId }) {
const iq = useIqAvailable();
const tabs = RESOURCE_TABS.filter((t) => !t.needsIq || iq);
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 (
<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">
@@ -32,11 +46,19 @@ export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex
))}
</div>
<div role="tabpanel" className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
{tab === 'commentary' && <CommentaryChunkTab resources={resources} chunk={chunk} project={project} chapterIndex={chapterIndex} updateChunk={updateChunk} notify={notify} />}
{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 === 'refs' && <CrossRefsTab crossRefs={crossRefs} 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>
);
+13 -1
View File
@@ -3,8 +3,9 @@
import { useState } from 'react';
import { NT_BOOK_NUMBER } from '../../utils/bibleUtils.jsx';
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 [editingNote, setEditingNote] = useState(null);
const isHebrew = chapter && !NT_BOOK_NUMBER[chapter.bookAbbrev];
return (
<section aria-label="Scripture" className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] px-5 py-4">
@@ -20,6 +21,17 @@ export default function ScripturePane({ verses, chapter, interlinear, interlinea
{v.chapter !== chapter?.chapter ? `${v.chapter}:` : ''}{v.number}
</button>
<span>{v.text}</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 && (
<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) => {
+42
View File
@@ -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>
);
}
+7 -8
View File
@@ -3,14 +3,14 @@
// the stage switcher, and save/sync status.
import { useApp } from '../../context/AppContext.js';
import { themeVars, READER_THEMES } from '../reader/readerThemes.js';
import { ChevronLeft } from '../reader/icons.jsx';
import HomeButton from '../reader/HomeButton.jsx';
export const STAGES = [
{ id: 'setup', label: 'Chunks' },
{ id: 'study', label: 'Study' },
{ id: 'outline', label: 'Outline' },
{ id: 'script', label: 'Script' },
{ id: 'export', label: 'Export' },
{ id: 'export', label: 'Finish' },
];
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="safe-top z-30 shrink-0 border-b border-[var(--r-line)] bg-[var(--r-bar)]">
<div className="mx-auto flex h-12 max-w-[1600px] items-center gap-3 px-3">
<button type="button" onClick={goHome} aria-label="Back to home"
className="flex h-9 items-center gap-0.5 rounded-lg pl-1 pr-2 text-[var(--r-sub)] hover:bg-black/5"><ChevronLeft /><span className="hidden text-xs sm:inline">Home</span></button>
<div className="min-w-0 max-w-[16rem] truncate text-sm font-medium">{project?.title ?? ''}</div>
<nav aria-label="Study stages" className="mx-auto flex gap-0.5">
<HomeButton onClick={goHome} />
<div className="hidden min-w-0 max-w-[16rem] truncate text-sm font-medium sm:block">{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">
{STAGES.map((s) => {
const disabled = s.id !== 'setup' && (!project || noChunks);
return (
<button key={s.id} type="button" disabled={disabled} aria-current={stage === s.id ? 'step' : undefined}
title={disabled ? 'Add a chunk in Chunks first' : undefined}
onClick={() => setCurrentPage(s.id)}
className={`rounded-full px-3.5 py-1.5 text-xs ${stage === s.id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'} disabled:opacity-40 disabled:hover:bg-transparent`}>
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}
</button>
);
})}
</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>
: saveStatus ? <span className="text-[var(--r-accent)]">{saveStatus}</span>
: syncStatus === 'syncing' ? <span>Syncing…</span>
+3 -1
View File
@@ -6,6 +6,7 @@ import { useToast } from '../components/Toasts.jsx';
import { bookOptions, CHAPTER_COUNTS, makeId, parseBibleChapter } from '../utils/bibleUtils.jsx';
import { fetchStudyChapter } from '../utils/studyText.js';
import { templateStarter } from '../utils/studyTemplates.js';
import { rememberTemplate } from '../utils/planStudy.js';
// Every chunk starts blank; all note fields exist from the start.
export function blankChunk(start, end, spilloverEndVerse = null, templateId = 'blank') {
@@ -192,7 +193,8 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
setShowAddChapterForm(false);
setErrorMessage('');
} 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 = {
id: makeId(),
title: setup.title,
+6 -1
View File
@@ -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.
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(() => {
let live = true;
iqAvailable().then((v) => { if (live) setOn(v); });
+19
View File
@@ -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;
}
+23
View File
@@ -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];
}
+33
View File
@@ -221,6 +221,37 @@ export function useProjectLibrary({
});
};
// 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
// removed (and straight away if the tab is closed first, so a deleted project never comes back).
const finishDelete = (id) => {
@@ -287,6 +318,8 @@ export function useProjectLibrary({
openStudyChunk,
renameProjectInStorage,
setProjectSeries,
saveChunkRecall,
saveChunkPatch,
deleteProject,
pullLatestFromServer,
};
+5 -4
View File
@@ -6,14 +6,15 @@ export function useShareLink(projectId) {
const [shareToken, setShareToken] = useState(null);
const [expiresAt, setExpiresAt] = useState(null);
const [hasPasscode, setHasPasscode] = useState(false);
const [revealAt, setRevealAt] = useState(null);
const [shareBusy, setShareBusy] = useState(false);
const [shareCopied, setShareCopied] = useState(false);
const [shareError, setShareError] = useState('');
const apply = (data) => { setShareToken(data.shareToken ?? null); setExpiresAt(data.expiresAt ?? null); setHasPasscode(!!data.hasPasscode); };
const apply = (data) => { setShareToken(data.shareToken ?? null); setExpiresAt(data.expiresAt ?? null); setHasPasscode(!!data.hasPasscode); setRevealAt(data.revealAt ?? null); };
useEffect(() => {
setShareToken(null); setExpiresAt(null); setHasPasscode(false); setShareError('');
setShareToken(null); setExpiresAt(null); setHasPasscode(false); setRevealAt(null); setShareError('');
if (!projectId) return undefined;
let active = true;
getShareStatus(projectId).then((result) => {
@@ -26,7 +27,7 @@ export function useShareLink(projectId) {
? `${window.location.origin}${window.location.pathname}?share=${shareToken}`
: '';
// Turns the link on, or changes its rules: { expiresInDays?: number | null, passcode?: string | null }
// Turns the link on, or changes its rules: { expiresInDays?: number | null, passcode?: string | null, revealAt?: number | null }
const enable = async (options = {}) => {
setShareBusy(true);
setShareError('');
@@ -52,5 +53,5 @@ export function useShareLink(projectId) {
});
};
return { shareToken, shareUrl, expiresAt, hasPasscode, shareBusy, shareCopied, shareError, enable, revoke, copy };
return { shareToken, shareUrl, expiresAt, hasPasscode, revealAt, shareBusy, shareCopied, shareError, enable, revoke, copy };
}
+1
View File
@@ -34,6 +34,7 @@ select {
/* iPhone and iPad: don't inflate text when the phone is turned sideways, no grey flash on tap, and no
double-tap-to-zoom delay on controls. */
html {
scrollbar-gutter: stable; /* pages with and without a scrollbar line up the same */
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
+1 -1
View File
@@ -70,7 +70,7 @@ export default function AdminPage() {
};
return (
<PageShell title="Admin" onBack={goHome} maxWidth="max-w-5xl">
<PageShell title="Admin" active="admin" onBack={goHome} maxWidth="max-w-5xl">
<main className="mx-auto max-w-5xl space-y-5 px-4 py-6">
<div className="flex gap-2">
<button type="button" onClick={() => setAdminTab('users')}
+9 -3
View File
@@ -1,6 +1,8 @@
// The Export stage: take the finished study out as a file, or share it read-only.
// The Finish stage: everything for when a study is ready to use: files, sharing, handouts, slides, importing notes, schedule, co-teachers and earlier versions.
import { useApp } from '../context/AppContext.js';
import { useShareLink } from '../hooks/useShareLink.js';
import SlidesExport from '../components/SlidesExport.jsx';
import ImportNotes from '../components/ImportNotes.jsx';
import HandoutExport from '../components/HandoutExport.jsx';
import SessionSchedule from '../components/SessionSchedule.jsx';
import VersionHistory from '../components/VersionHistory.jsx';
@@ -30,7 +32,7 @@ export default function ExportPage() {
<StudioShell stage="export">
<main className="mx-auto max-w-2xl space-y-5 px-4 py-8">
<div>
<h1 className="text-xl font-medium">Export</h1>
<h1 className="text-xl font-medium">Finish</h1>
<p className="mt-1 text-sm text-[var(--r-sub)]">{allChunks.length} chunk{allChunks.length === 1 ? '' : 's'} · {filled} script{filled === 1 ? '' : 's'} written</p>
</div>
@@ -52,7 +54,7 @@ export default function ExportPage() {
className="min-w-0 flex-1 truncate rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-xs text-[var(--r-sub)]" />
<button type="button" onClick={share.copy} className="shrink-0 rounded-lg bg-[var(--r-accent)] px-4 py-2 text-xs font-medium text-[var(--r-on)]">{share.shareCopied ? 'Copied!' : 'Copy'}</button>
</div>
<LinkRules expiresAt={share.expiresAt} hasPasscode={share.hasPasscode} busy={share.shareBusy} onSave={share.enable} />
<LinkRules expiresAt={share.expiresAt} hasPasscode={share.hasPasscode} busy={share.shareBusy} onSave={share.enable} showReveal revealAt={share.revealAt} />
{share.shareError && <p role="alert" className="text-xs text-[var(--r-danger,#b42318)]">{share.shareError}</p>}
<button type="button" onClick={share.revoke} disabled={share.shareBusy} className="text-xs text-[var(--r-danger,#b42318)] hover:underline">{share.shareBusy ? 'Revoking…' : 'Revoke link'}</button>
</div>
@@ -63,6 +65,10 @@ export default function ExportPage() {
<HandoutExport project={project} />
<SlidesExport project={project} />
<ImportNotes />
<SessionSchedule project={project} />
<CoTeachers projectId={project?.id} sharedBy={project && sharedStudies?.[project.id]} onLeave={() => { deleteProject(project.id); setCurrentPage('home'); }} />
+30 -4
View File
@@ -1,9 +1,14 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { filterStudies, groupBySeries, isTagQuery, seriesList, tagCounts } from '../utils/studyFilter.js';
import BookCover from '../components/BookCover.jsx';
import DailyFive from '../components/DailyFive.jsx';
import WeeklyRecall from '../components/WeeklyRecall.jsx';
import OfflineBadge from '../components/OfflineBadge.jsx';
import { useOfflineSave } from '../hooks/useOfflineSave.js';
import RecallHistory from '../components/RecallHistory.jsx';
import IqDailyCard from '../components/iq/IqDailyCard.jsx';
import IqNarratorOptions from '../components/iq/IqNarratorOptions.jsx';
import IqNarratorOptions, { useLazyNarrators } from '../components/iq/IqNarratorOptions.jsx';
import PageShell from '../components/study/PageShell.jsx';
import AudioPlayer from '../components/AudioPlayer.jsx';
import {
@@ -12,6 +17,7 @@ import {
formatRelativeDate,
loadProjectIndex,
} from '../utils/bibleUtils.jsx';
import { backupAge, lastBackupAt } from '../utils/backup.js';
import { loadProjectById } from '../utils/projectStorage.js';
import { compactPassages } from '../utils/passageSummary.js';
import { getPlan, getPlanSchedule, formatDayReadings, nextUnreadDayIndex, computeStreak } from '../utils/readingPlans.js';
@@ -36,6 +42,8 @@ export default function HomePage() {
audioState,
audioProgress,
openImportProject,
setCurrentPage,
openBackup,
openNewProject,
pullLatestFromServer,
resumeProject,
@@ -49,6 +57,10 @@ export default function HomePage() {
handleSeekAudio,
handleSeekAudioTo,
} = useApp();
const narrators = useLazyNarrators(audioNarrator);
const offline = useOfflineSave();
// Each study's books and translation, read once per list change (the badge itself follows what's saved).
const studyBooks = useMemo(() => Object.fromEntries(projectIndex.map((e) => [e.id, loadProjectById(e.id)])), [projectIndex]);
const [homeSearch, setHomeSearch] = useState('');
const [homeSort, setHomeSort] = useState(() => {
try { return localStorage.getItem('homeSort') || 'recent'; } catch { return 'recent'; }
@@ -127,6 +139,7 @@ export default function HomePage() {
active="studies"
actions={(
<>
<button type="button" onClick={() => setCurrentPage('insights')} className={linkBtn} title="This week, themes, words, what slipped, follow-ups">Insights</button>
<button type="button" onClick={openImportProject} className={linkBtn} title="Set up a whole series from a session list (.docx)">Import Session List</button>
<button type="button" onClick={openNewProject} className="rounded-full bg-[var(--r-accent)] px-4 py-1.5 text-sm font-medium text-[var(--r-on)] hover:opacity-90">+ New Project</button>
</>
@@ -134,6 +147,18 @@ export default function HomePage() {
>
<main className="mx-auto grid max-w-6xl gap-6 px-4 py-6 lg:grid-cols-[minmax(0,1fr)_21rem]">
<section className="min-w-0">
{projectIndex.length > 0 && (() => {
const age = backupAge(lastBackupAt());
return (
<p className={`mb-3 flex flex-wrap items-center gap-2 px-1 text-xs ${age.stale ? 'text-[var(--r-danger,#b42318)]' : 'text-[var(--r-sub)]'}`}>
{age.text}.
<button type="button" onClick={openBackup} className="text-[var(--r-accent)] hover:underline">Back up now</button>
</p>
);
})()}
<DailyFive />
<WeeklyRecall />
<RecallHistory />
<IqDailyCard />
{autoRestoredCount !== null && (
<div className={notice}>Synced {autoRestoredCount} project{autoRestoredCount > 1 ? 's' : ''} from another device.</div>
@@ -302,6 +327,7 @@ export default function HomePage() {
</div>
)}
<p className="mt-1 text-xs text-[var(--r-sub)]">{formatRelativeDate(entry.lastEdited)}</p>
<OfflineBadge project={studyBooks[entry.id]} offline={offline} />
</div>
<div className={`flex items-center gap-2 ${homeView === 'list' ? 'sm:w-56 sm:shrink-0' : ''}`}>
<button type="button" onClick={() => resumeProject(entry.id)} className="flex-1 rounded-xl bg-[var(--r-text)] px-4 py-2 text-sm font-medium text-[var(--r-bg)] hover:opacity-90">Resume</button>
@@ -343,11 +369,11 @@ export default function HomePage() {
<select value={audioBook} onChange={(e) => setAudioBook(e.target.value)} disabled={audioState.status === 'playing' || audioState.status === 'paused'} className={fieldCls}>
{bookOptions.map((book) => <option key={book.abbrev} value={book.abbrev}>{book.name}</option>)}
</select>
<select value={audioNarrator} onChange={(e) => setAudioNarrator(e.target.value)} disabled={audioState.status === 'playing' || audioState.status === 'paused'} className={fieldCls}>
<select value={audioNarrator} onChange={(e) => setAudioNarrator(e.target.value)} disabled={audioState.status === 'playing' || audioState.status === 'paused'} className={fieldCls} {...narrators.selectProps}>
<option value="david">David</option>
<option value="hays">Hays</option>
<option value="souer">Souer</option>
<IqNarratorOptions />
<IqNarratorOptions enabled={narrators.enabled} />
</select>
</>
)}
+107
View File
@@ -0,0 +1,107 @@
// Insights: your studies seen together — this week's own work, themes (tags), words you keep studying,
// what slipped in recalls, and follow-ups still open.
import { useMemo } from 'react';
import { useApp } from '../context/AppContext.js';
import PageShell from '../components/study/PageShell.jsx';
import { loadProjectById } from '../utils/projectStorage.js';
import { followUpList, slippedList, themeIndex, weeklySummary, wordIndex } from '../utils/insights.js';
const card = 'rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5';
const open = 'text-xs text-[var(--r-accent)] hover:underline';
function Stat({ label, value }) {
return (
<div className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-4 py-3">
<p className="text-2xl font-medium tabular-nums">{value}</p>
<p className="text-xs text-[var(--r-sub)]">{label}</p>
</div>
);
}
export default function InsightsPage() {
const { projectIndex, openStudyChunk, goHome } = useApp();
const data = useMemo(() => {
const projects = projectIndex.map((e) => loadProjectById(e.id)).filter(Boolean);
return { week: weeklySummary(projects), themes: themeIndex(projects), words: wordIndex(projects), slipped: slippedList(projects), followUps: followUpList(projects) };
}, [projectIndex]);
const { week, themes, words, slipped, followUps } = data;
const link = (item) => <button type="button" onClick={() => openStudyChunk(item.projectId, item.chunkId)} className={open}>{item.reference} · {item.projectTitle}</button>;
return (
<PageShell title="Insights" active="studies" onBack={goHome} maxWidth="max-w-4xl">
<main className="mx-auto max-w-4xl space-y-5 px-4 pb-10">
<section aria-label="This week" className={card}>
<h2 className="text-sm font-medium">This week</h2>
<p className="mb-3 mt-0.5 text-xs text-[var(--r-sub)]">Counted over the chunks you edited in the last 7 days. Pasted text isn’t counted as yours.</p>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat label="Chunks worked on" value={week.chunksTouched} />
<Stat label="Words of your own" value={week.ownWords} />
<Stat label="Your own words" value={week.ownShare === null ? '–' : `${week.ownShare}%`} />
<Stat label="Recalls done" value={week.recalls} />
</div>
{(week.teachBacks > 0 || week.fiveMinutes > 0) && <p className="mt-2 text-xs text-[var(--r-sub)]">{[week.fiveMinutes > 0 && `${week.fiveMinutes} five-minute session${week.fiveMinutes === 1 ? '' : 's'}`, week.teachBacks > 0 && `${week.teachBacks} teach-it-back summar${week.teachBacks === 1 ? 'y' : 'ies'}`].filter(Boolean).join(' · ')} this week.</p>}
</section>
<section aria-label="Open follow-ups" className={card}>
<h2 className="text-sm font-medium">Follow-ups</h2>
{followUps.length === 0 ? <p className="mt-1 text-xs text-[var(--r-sub)]">None yet. Add them under “More” in the Study stage.</p> : (
<ul className="mt-2 space-y-1.5 text-sm">
{followUps.map((f) => (
<li key={f.id} className="flex flex-wrap items-baseline gap-x-3">
<span className={f.done ? 'text-[var(--r-sub)] line-through' : ''}>{f.text}</span>
{link(f)}
</li>
))}
</ul>
)}
</section>
<section aria-label="What slipped" className={card}>
<h2 className="text-sm font-medium">What slipped in recalls</h2>
{slipped.length === 0 ? <p className="mt-1 text-xs text-[var(--r-sub)]">Nothing yet. After a weekly recall, note what you missed.</p> : (
<ul className="mt-2 space-y-3 text-sm">
{slipped.map((r) => (
<li key={`${r.chunkId}-${r.at}`}>
<p className="text-xs text-[var(--r-sub)]">{new Date(r.at).toLocaleDateString()} · {link(r)}</p>
<ul className="ml-4 list-disc">{r.items.map((i) => <li key={i}>{i}</li>)}</ul>
</li>
))}
</ul>
)}
</section>
<section aria-label="Themes" className={card}>
<h2 className="text-sm font-medium">Themes</h2>
{themes.length === 0 ? <p className="mt-1 text-xs text-[var(--r-sub)]">Tag a chunk (under “More”) and it appears here with every other chunk on the same theme.</p> : (
<div className="mt-2 space-y-3">
{themes.map((t) => (
<details key={t.tag} className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2">
<summary className="cursor-pointer text-sm"><span className="font-medium">{t.tag}</span> <span className="text-xs text-[var(--r-sub)]">· {t.count} chunk{t.count === 1 ? '' : 's'}</span></summary>
<ul className="mt-2 space-y-2">
{t.items.map((i) => (
<li key={`${i.chunkId}`} className="text-sm">{link(i)}{i.snippet && <p className="font-serif text-[13px] text-[var(--r-sub)]">{i.snippet}</p>}</li>
))}
</ul>
</details>
))}
</div>
)}
</section>
<section aria-label="Words you keep studying" className={card}>
<h2 className="text-sm font-medium">Words you keep studying</h2>
{words.length === 0 ? <p className="mt-1 text-xs text-[var(--r-sub)]">Words you study in two or more places show up here.</p> : (
<ul className="mt-2 space-y-2 text-sm">
{words.map((w) => (
<li key={w.key}>
<span className="font-serif text-base">{w.lexeme}</span> <span className="text-xs text-[var(--r-sub)]">{[w.transliteration, w.strong, w.gloss].filter(Boolean).join(' · ')} · {w.count} places</span>
<span className="mt-0.5 flex flex-wrap gap-x-3">{w.places.map((p) => <span key={p.chunkId}>{link(p)}</span>)}</span>
</li>
))}
</ul>
)}
</section>
</main>
</PageShell>
);
}
+14 -2
View File
@@ -11,12 +11,13 @@ import { buildChapterSummary } from '../utils/projectStorage.js';
import { compactPassages } from '../utils/passageSummary.js';
import { attachableFromChunk, fetchPassageText } from '../utils/passageText.js';
import PresentMode from '../components/PresentMode.jsx';
import { firstOverPoint, outlineSteps, planStatus } from '../utils/present.js';
import IqOutlineAttach from '../components/iq/IqOutlineAttach.jsx';
import ShareOutlineSheet from '../components/ShareOutlineSheet.jsx';
import { downloadOutlineDocx, downloadOutlineMarkdown, printOutline } from '../utils/outlineExport.js';
import {
KINDS, STUDY, buildChunkFromNotes, buildFromNotes, notesBuildCount, addAfter, addChild, addRoot, chunkOf, countNodes, flatten, getNode, indent, insertAt, kindLabel, locate,
moveNode, newNode, outdent, outlineFor, outlineToText, removeNode, seedFromChunks, seedFromVerses, timeSummary, updateNode, withOutline,
moveNode, newNode, outdent, outlineFor, outlineToText, removeNode, seedFromChunks, seedFromVerses, timeSummary, totalMinutes, updateNode, withOutline,
} from '../utils/outline.js';
const iconBtn = 'flex h-7 w-7 items-center justify-center rounded-md text-sm text-[var(--r-sub)] hover:bg-black/5 hover:text-[var(--r-text)] disabled:opacity-30 disabled:hover:bg-transparent';
@@ -45,6 +46,11 @@ export default function OutlinePage() {
const scopeChunk = scope === STUDY ? null : chunkOf(project, scope);
const tree = outlineFor(project, scope);
const rows = flatten(tree);
// The time you have for a session, and how the planned minutes measure up to it
const sessionMinutes = Number(project?.sessionMinutes) || 0;
const plan = planStatus(totalMinutes(tree), sessionMinutes);
const overPoint = firstOverPoint(outlineSteps(tree), sessionMinutes);
const setSessionMinutes = (value) => updateProject((current) => ({ ...current, sessionMinutes: value === '' ? null : Math.max(0, Math.min(600, Math.round(Number(value)))) }));
const [pendingFocus, setPendingFocus] = useState(null);
const [metaOpen, setMetaOpen] = useState(() => new Set()); // points whose detail fields are showing
const [shareOpen, setShareOpen] = useState(false);
@@ -160,6 +166,11 @@ export default function OutlinePage() {
</select>
</label>
{timeSummary(tree) && <span className="text-sm text-[var(--r-sub)]">{timeSummary(tree)}</span>}
<label className="flex items-center gap-1.5 text-sm text-[var(--r-sub)]">Session length
<input type="number" min="0" max="600" inputMode="numeric" aria-label="Session length in minutes" value={project?.sessionMinutes ?? ''} onChange={(e) => setSessionMinutes(e.target.value)}
className="w-16 rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1 text-sm text-[var(--r-text)] focus:outline-none" /> min
</label>
{plan.state !== 'unset' && <span role="status" className={`text-sm ${plan.state === 'over' ? 'font-medium text-[var(--r-danger,#b42318)]' : 'text-[var(--r-sub)]'}`}>{plan.text}</span>}
{scopeChunk && <button type="button" onClick={() => openChunk(scope)} className="text-sm text-[var(--r-accent)] hover:underline">Open this chunk in Study</button>}
</div>
@@ -229,10 +240,11 @@ export default function OutlinePage() {
/>
{/* What the point carries, at a glance */}
{(node.kind || mins || node.ref || linked) && (
{(node.kind || mins || node.ref || linked || overPoint?.id === node.id) && (
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 px-2 text-xs text-[var(--r-sub)]">
{node.kind && <span className={`${chip} border-[var(--r-accent)] text-[var(--r-accent)]`}>{kindLabel(node.kind)}</span>}
{mins && <span>{node.minutes} min</span>}
{overPoint?.id === node.id && <span className="font-medium text-[var(--r-danger,#b42318)]">Runs past {sessionMinutes} min here</span>}
{node.ref && <span>{node.ref}</span>}
{linked && node.chunkId !== scope && <button type="button" onClick={() => openChunk(node.chunkId)} className="text-[var(--r-accent)] hover:underline">Open chunk in Study</button>}
</p>
+1 -1
View File
@@ -1,5 +1,5 @@
// Every Greek and Hebrew word in the study with its transliteration, so a
// speaker can rehearse them. Reached from the Script and Export stages.
// speaker can rehearse them. Reached from the Script and Finish stages.
import { useState } from 'react';
import { useApp } from '../context/AppContext.js';
import StudioShell from '../components/study/StudioShell.jsx';
+29 -3
View File
@@ -11,6 +11,7 @@ import { useToast } from '../components/Toasts.jsx';
import StudioShell from '../components/study/StudioShell.jsx';
import { formatChunkReference } from '../utils/bibleUtils.jsx';
import { buildClaudePrompt, buildPodcastPrompt } from '../utils/exportBuilders.js';
import { chunksNeedingWork } from '../utils/ownWork.js';
import { buildScriptDraft, spokenMinutes, wordCount } from '../utils/scriptDraft.js';
const has = (s) => (s ?? '').trim().length > 0;
@@ -23,6 +24,7 @@ export default function ScriptPage() {
const iq = useIqAvailable();
const [gathering, setGathering] = useState(false);
const [presenting, setPresenting] = useState(false);
const [heldAction, setHeldAction] = useState(null); // an AI hand-off waiting on the own-work check
const { toast } = useToast();
const chunk = allChunks.find((c) => c.id === project?.selectedChunkId) ?? allChunks[0] ?? null;
@@ -54,6 +56,11 @@ export default function ScriptPage() {
}
};
// AI hand-offs only go straight through once every chunk has your own notes; otherwise ask first.
const needingWork = chunksNeedingWork(allChunks);
const guarded = (action) => () => { if (needingWork.length === 0) action(); else setHeldAction(() => action); };
const goStudy = (id) => { select(id); setHeldAction(null); setCurrentPage('study'); };
// Copies a prompt, then opens claude.ai so it can be pasted straight in.
const copyPrompt = (prompt) => {
navigator.clipboard.writeText(prompt).then(() => {
@@ -130,12 +137,12 @@ export default function ScriptPage() {
<input type="file" accept=".docx" className="hidden" onChange={(e) => { importDocx(e.target.files?.[0]); e.target.value = ''; }} />
</label>
<span className="mx-1 h-4 w-px bg-[var(--r-line)]" />
<button type="button" onClick={() => copyPrompt(buildClaudePrompt(project))} className={btn}
<button type="button" onClick={guarded(() => copyPrompt(buildClaudePrompt(project)))} className={btn}
title="Copies your study notes as a Claude prompt, then opens claude.ai">Study guide → Claude ↗</button>
<button type="button" onClick={() => copyPrompt(buildPodcastPrompt(project, authUser?.podcastName))} className={btn}
<button type="button" onClick={guarded(() => copyPrompt(buildPodcastPrompt(project, authUser?.podcastName)))} className={btn}
title="Copies a podcast script prompt for Claude — set your show name in Account Settings first">Podcast → Claude ↗</button>
{iq && (
<button type="button" disabled={gathering} onClick={copyPodcastWithContext} className={btn}
<button type="button" disabled={gathering} onClick={guarded(copyPodcastWithContext)} className={btn}
title={`Adds IQ Bible commentary, cross-references and word glosses for up to ${chunkRefs(project).refs.length} passages (each uses a few API requests)`}>
{gathering ? 'Gathering…' : 'Podcast + background → Claude ↗'}
</button>
@@ -145,6 +152,25 @@ export default function ScriptPage() {
<DictateButton label="Dictate script" onText={(said) => set({ finalScript: appendSpoken(script, said) })} />
{notice && <span role="status" className="text-xs text-[var(--r-accent)]">{notice}</span>}
</div>
{heldAction && (
<div role="alert" className="rounded-xl border border-[var(--r-accent)] bg-[var(--r-hl)] p-3 text-sm">
<p className="font-medium">Is this your study, or Claude's?</p>
<p className="mt-0.5 text-xs text-[var(--r-sub)]">These chunks don't have much of your own thinking yet. Write it first, then let Claude polish it.</p>
<ul className="mt-2 space-y-1 text-xs">
{needingWork.map(({ chunk: c, missing }) => (
<li key={c.id} className="flex items-center justify-between gap-3">
<span>{formatChunkReference(project, project.chapters.findIndex((ch) => ch.chunks.some((x) => x.id === c.id)), c, '–')}: {missing.map((m) => `${m.label} ${m.have}/${m.need}`).join(', ')}</span>
<button type="button" onClick={() => goStudy(c.id)} className="text-[var(--r-accent)] hover:underline">Write it →</button>
</li>
))}
</ul>
<div className="mt-2.5 flex gap-2">
<button type="button" onClick={() => goStudy(needingWork[0].chunk.id)} className="rounded-full bg-[var(--r-text)] px-3 py-1 text-xs font-medium text-[var(--r-bg)]">Go write my notes</button>
<button type="button" onClick={() => { const run = heldAction; setHeldAction(null); run(); }} className="rounded-full border border-[var(--r-text)] px-3 py-1 text-xs hover:bg-black/5">Send to Claude anyway</button>
<button type="button" onClick={() => setHeldAction(null)} className="text-xs text-[var(--r-sub)] hover:underline">Cancel</button>
</div>
</div>
)}
<textarea aria-label="Final script" value={script} onChange={(e) => set({ finalScript: e.target.value })}
placeholder="Write or paste the final script here. “Build outline from notes” starts you from your study notes."
className="min-h-[24rem] flex-1 resize-y rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] px-5 py-4 font-serif text-[16px] leading-8 text-[var(--r-text)] placeholder:font-sans placeholder:text-sm placeholder:text-[var(--r-sub)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" />
+29 -4
View File
@@ -1,10 +1,13 @@
import { normalizeNtfyServer } from '../utils/ntfyServer.js';
import IqNarratorOptions from '../components/iq/IqNarratorOptions.jsx';
import IqNarratorOptions, { useLazyNarrators } from '../components/iq/IqNarratorOptions.jsx';
import { useEffect, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import PageShell from '../components/study/PageShell.jsx';
import { READER_TRANSLATIONS } from '../utils/bibleText.js';
import DataSection from '../components/settings/DataSection.jsx';
import { DashboardSettings, HighlightColours, StudyTemplates, VerseOfDaySettings } from '../components/settings/MoreSettings.jsx';
import { useLocalSetting } from '../hooks/useLocalSetting.js';
import { useIqAvailable } from '../hooks/useIq.js';
import PasskeysSection from '../components/settings/PasskeysSection.jsx';
import DevicesSection from '../components/settings/DevicesSection.jsx';
import { COMMENTARIES } from '../hooks/useVerseStudy.js';
@@ -61,10 +64,14 @@ export default function SettingsPage() {
readerWide, setReaderWide,
readerCommentary, setReaderCommentary,
audioNarrator, setAudioNarrator,
readerFont, setReaderFont, readerSpacing, setReaderSpacing, readerVerseNumbers, setReaderVerseNumbers,
settingsFocus, clearSettingsFocus,
} = useApp();
// "Back up my data" from the ⌘K palette lands on that section
const narrators = useLazyNarrators(audioNarrator);
const iqOn = useIqAvailable();
const [iqMode, setIqMode] = useLocalSetting('iq-commentary-mode', '0');
const [tab, setTab] = useState(settingsFocus === 'backup' ? 'data' : 'account');
useEffect(() => {
if (!settingsFocus) return;
@@ -234,7 +241,7 @@ export default function SettingsPage() {
};
return (
<PageShell title="Account Settings" onBack={goHome} maxWidth="max-w-2xl">
<PageShell title="Account Settings" active="settings" onBack={goHome} maxWidth="max-w-2xl">
<main className="mx-auto max-w-2xl space-y-5 px-4 py-6">
<div role="tablist" aria-label="Settings sections" className="-mx-1 flex gap-1 overflow-x-auto px-1 pb-1">
{SETTINGS_TABS.map(([id, label]) => (
@@ -283,18 +290,32 @@ export default function SettingsPage() {
{COMMENTARIES.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</PrefRow>
{iqOn && (
<PrefRow label="Commentary sources" hint="Classic is the short list. More sources adds IQ Bible’s many commentaries.">
<Segmented label="Commentary sources" options={[['0', 'Classic'], ['1', 'More sources']]} value={iqMode} onChange={setIqMode} />
</PrefRow>
)}
<PrefRow label="Text size" hint="Reader text.">
<Segmented label="Text size" options={FONT_SIZES.map(([l, n]) => [n, l])} value={readerFontSize} onChange={setReaderFontSize} />
</PrefRow>
<PrefRow label="Typeface">
<Segmented label="Typeface" options={[['serif', 'Serif'], ['sans', 'Sans'], ['easy', 'Easy to read']]} value={readerFont} onChange={setReaderFont} />
</PrefRow>
<PrefRow label="Line spacing">
<Segmented label="Line spacing" options={[['snug', 'Snug'], ['normal', 'Normal'], ['airy', 'Airy']]} value={readerSpacing} onChange={setReaderSpacing} />
</PrefRow>
<PrefRow label="Verse numbers">
<Segmented label="Verse numbers" options={[[true, 'Show'], [false, 'Hide']]} value={readerVerseNumbers} onChange={setReaderVerseNumbers} />
</PrefRow>
<PrefRow label="Reading width" hint="One column, or two on a wide screen.">
<Segmented label="Reading width" options={[[false, 'Column'], [true, 'Two columns']]} value={readerWide} onChange={setReaderWide} />
</PrefRow>
<PrefRow label="Audio narrator" hint="For the Berean Standard Bible audio. IQ Bible narrations count against its monthly audio allowance.">
<select aria-label="Audio narrator" value={audioNarrator} onChange={(e) => setAudioNarrator(e.target.value)} className={selectCls}>
<select aria-label="Audio narrator" value={audioNarrator} onChange={(e) => setAudioNarrator(e.target.value)} className={selectCls} {...narrators.selectProps}>
<option value="david">David</option>
<option value="hays">Hays</option>
<option value="souer">Souer</option>
<IqNarratorOptions />
<IqNarratorOptions enabled={narrators.enabled} />
</select>
</PrefRow>
</section>
@@ -388,6 +409,10 @@ export default function SettingsPage() {
<DataSection />
)}
{tab === 'prefs' && <HighlightColours />}
{tab === 'prefs' && <DashboardSettings />}
{tab === 'prefs' && <VerseOfDaySettings />}
{tab === 'prefs' && <StudyTemplates />}
{tab === 'prefs' && (
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-4">
<div>
+55 -8
View File
@@ -13,10 +13,15 @@ import OutlineRail from '../components/study/OutlineRail.jsx';
import ScripturePane from '../components/study/ScripturePane.jsx';
import NotesPane from '../components/study/NotesPane.jsx';
import ResourcePane, { useResourceTab } from '../components/study/ResourcePane.jsx';
import CompareSheet from '../components/study/CompareSheet.jsx';
import SuggestWordsSheet from '../components/study/SuggestWordsSheet.jsx';
// Keyboard-only study: 1 / 2 / 3 jump to Observe / Interpret / Apply; c, w, x, p, r open the resource tabs; v compares.
const NOTE_KEYS = { 1: 'observation', 2: 'interpretation', 3: 'application' };
const TAB_KEYS = { c: 'commentary', w: 'words', x: 'refs', p: 'people', r: 'readings' };
export default function StudyPage() {
const { project, allChunks, projectIndex, updateProject, updateChunk, setCurrentPage, readerCommentary, setReaderCommentary, saveUserTemplate } = useApp();
const { project, allChunks, projectIndex, updateProject, updateChunk, setCurrentPage, readerCommentary, setReaderCommentary, saveUserTemplate, startPrayerFor } = useApp();
// Short-lived status line (word suggestions, lookups, …)
const [statusMessage, setStatusMessage] = useState('');
@@ -41,6 +46,26 @@ export default function StudyPage() {
const [tab, setTab] = useResourceTab();
const [openWordId, setOpenWordId] = useState(null);
const [comparing, setComparing] = useState(false);
// Either half of the right column can be folded away so the other fills it (remembered on this device).
const [folded, setFolded] = useState(() => {
try { const f = JSON.parse(localStorage.getItem('study-folded')); return { notes: !!f?.notes, resources: !!f?.resources && !f?.notes }; } catch { return { notes: false, resources: false }; }
});
const [focusRequest, setFocusRequest] = useState(null);
const showHalf = (which) => setFolded((cur) => {
if (!cur[which]) return cur;
const next = { ...cur, [which]: false };
try { localStorage.setItem('study-folded', JSON.stringify(next)); } catch { /* storage unavailable */ }
return next;
});
const toggleFold = (which) => setFolded((cur) => {
const other = which === 'notes' ? 'resources' : 'notes';
// folding the last open half opens the other one instead, so the column is never empty
const next = { ...cur, [which]: !cur[which] };
if (next[which] && next[other]) next[other] = false;
try { localStorage.setItem('study-folded', JSON.stringify(next)); } catch { /* storage unavailable */ }
return next;
});
useEffect(() => { setOpenWordId(null); }, [selectedChunk?.id]);
const resources = useStudyResources({
@@ -62,11 +87,15 @@ export default function StudyPage() {
const editable = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || event.target?.isContentEditable;
if (event.key === 'Escape') {
if (wordStudy.suggestModal) { wordStudy.setSuggestModal(null); wordStudy.setSuggestSelection(new Set()); }
else if (editable && !comparing) event.target.blur(); // leave a note box and go back to keyboard moves
return;
}
if (editable || wordStudy.suggestModal) return;
if (editable || wordStudy.suggestModal || comparing || event.metaKey || event.ctrlKey || event.altKey) return;
if (event.key === 'ArrowLeft') { event.preventDefault(); goPrev(); }
else if (event.key === 'ArrowRight') { event.preventDefault(); goNext(); }
else if (NOTE_KEYS[event.key]) { event.preventDefault(); showHalf('notes'); setFocusRequest({ field: NOTE_KEYS[event.key], n: Date.now() }); }
else if (TAB_KEYS[event.key.toLowerCase()]) { event.preventDefault(); showHalf('resources'); setTab(TAB_KEYS[event.key.toLowerCase()]); }
else if (event.key.toLowerCase() === 'v') { event.preventDefault(); setComparing(true); }
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
@@ -103,6 +132,7 @@ export default function StudyPage() {
)}
</div>
<div className="flex shrink-0 gap-1.5">
<button type="button" onClick={() => setComparing(true)} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5" title="Set another passage beside this one">Compare…</button>
<button type="button" onClick={goPrev} disabled={globalIndex <= 0} aria-label="Previous chunk"
className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-sm hover:bg-black/5 disabled:opacity-40">‹</button>
<button type="button" onClick={goNext} disabled={globalIndex >= allChunks.length - 1} aria-label="Next chunk"
@@ -114,6 +144,8 @@ export default function StudyPage() {
verses={verses} chapter={chapter}
interlinear={resources.interlinearData} interlinearLoading={resources.interlinearLoading}
addedStrongs={addedStrongs} onPickWord={pickWord}
verseNotes={selectedChunk.verseNotes ?? {}}
onVerseNote={(key, text) => updateChunk(selectedChunk.id, { verseNotes: { ...(selectedChunk.verseNotes ?? {}), [key]: text } })}
/>
</div>
) : (
@@ -129,25 +161,40 @@ export default function StudyPage() {
</div>
{selectedChunk && chapter && (
<>
<section aria-label="Notes" className="flex min-h-[22rem] flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] lg:min-h-0 lg:flex-[11]">
<h2 className="shrink-0 border-b border-[var(--r-line)] px-4 py-2.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Your notes</h2>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<NotesPane chunk={selectedChunk} updateChunk={updateChunk} allTags={allTags} templateId={project?.templateDef ?? project?.templateId} saveTemplate={saveUserTemplate} notify={notify} />
<section aria-label="Notes" className={`flex flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] ${folded.notes ? 'shrink-0' : 'min-h-[22rem] lg:min-h-0 lg:flex-[11]'}`}>
<h2 className="shrink-0">
<button type="button" onClick={() => toggleFold('notes')} aria-expanded={!folded.notes}
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-[11px] uppercase tracking-wide text-[var(--r-sub)] hover:bg-black/5">
Your notes<span aria-hidden="true">{folded.notes ? '▸' : '▾'}</span>
</button>
</h2>
<div className={`min-h-0 flex-1 overflow-y-auto border-t border-[var(--r-line)] p-3 ${folded.notes ? 'hidden' : ''}`}>
<NotesPane chunk={selectedChunk} updateChunk={updateChunk} allTags={allTags} templateId={project?.templateDef ?? project?.templateId} saveTemplate={saveUserTemplate} notify={notify} focusRequest={focusRequest}
onPray={() => startPrayerFor(formatChunkReference(project, chapterIndex, selectedChunk, '–'))} />
</div>
</section>
<section aria-label="Resources" className="flex min-h-[24rem] flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] lg:min-h-0 lg:flex-[10]">
<section aria-label="Resources" className={`flex flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] ${folded.resources ? 'shrink-0' : 'min-h-[24rem] lg:min-h-0 lg:flex-[10]'}`}>
<h2 className="shrink-0">
<button type="button" onClick={() => toggleFold('resources')} aria-expanded={!folded.resources}
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-[11px] uppercase tracking-wide text-[var(--r-sub)] hover:bg-black/5">
Commentary &amp; resources<span aria-hidden="true">{folded.resources ? '▸' : '▾'}</span>
</button>
</h2>
<div className={`flex min-h-0 flex-1 flex-col border-t border-[var(--r-line)] ${folded.resources ? 'hidden' : ''}`}>
<ResourcePane
tab={tab} setTab={setTab} chunk={selectedChunk} chapter={chapter} chapterIndex={chapterIndex} project={project}
updateChunk={updateChunk} notify={notify}
updateChunk={updateChunk} updateProject={updateProject} notify={notify}
resources={resources} wordStudy={wordStudy} crossRefs={crossRefs}
openWordId={openWordId} setOpenWordId={setOpenWordId}
/>
</div>
</section>
</>
)}
</aside>
</div>
{comparing && selectedChunk && <CompareSheet currentChunkId={selectedChunk.id} onClose={() => setComparing(false)} />}
<SuggestWordsSheet wordStudy={wordStudy} />
{statusMessage && (
<div role="status" className="fixed bottom-6 left-1/2 z-[60] -translate-x-1/2 rounded-full bg-[var(--r-text)] px-4 py-2 text-sm text-[var(--r-bg)] shadow-lg">
+25
View File
@@ -83,6 +83,31 @@ describe('Authentication', () => {
expect(await screen.findByDisplayValue('My Show')).toBeInTheDocument();
});
test('every reading and display preference can be set from Settings', async () => {
stubApi({ session: user });
const u = userEvent.setup();
render(<App />);
await openAccountMenu(u);
await u.click(await screen.findByRole('menuitem', { name: /settings/i }));
await u.click(await screen.findByRole('tab', { name: 'Preferences' }));
for (const name of ['Text size', 'Typeface', 'Line spacing', 'Verse numbers', 'Reading width', 'Verse layout', 'Theme', 'Home page']) {
expect(screen.getByRole('group', { name })).toBeInTheDocument();
}
await u.click(within(screen.getByRole('group', { name: 'Typeface' })).getByRole('button', { name: 'Sans' }));
expect(localStorage.getItem('reader-font')).toBe('sans');
await u.click(within(screen.getByRole('group', { name: 'Verse numbers' })).getByRole('button', { name: 'Hide' }));
expect(localStorage.getItem('reader-verse-numbers')).toBe('off');
// highlight colour names
await u.type(screen.getByLabelText('Name for yellow highlights'), 'Promise');
expect(JSON.parse(localStorage.getItem('reader-color-labels'))['#fef08a']).toBe('Promise');
// the My Studies page
await u.click(within(screen.getByRole('group', { name: 'Show studies as' })).getByRole('button', { name: 'List' }));
expect(localStorage.getItem('homeView')).toBe('list');
await u.selectOptions(screen.getByLabelText('Sort studies by'), 'title');
expect(localStorage.getItem('homeSort')).toBe('title');
expect(screen.getByRole('heading', { name: 'Study templates' })).toBeInTheDocument();
});
test('Settings is split into tabs, one group at a time', async () => {
stubApi({ session: user });
const u = userEvent.setup();
+102 -7
View File
@@ -190,16 +190,16 @@ describe('Home page', () => {
});
// ---------------------------------------------------------------------------
// Export stage (useShareLink) and the shared read-only view
// Finish stage (useShareLink) and the shared read-only view
// ---------------------------------------------------------------------------
describe('Project header and sharing', () => {
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
test('Export stage → Markdown downloads a file', async () => {
test('Finish stage → Markdown downloads a file', async () => {
const user = await loadChapter();
addChunk('Paul, a servant', 'Paul, a servant');
await screen.findByText('1 created');
await user.click(screen.getByRole('button', { name: /^export$/i }));
await user.click(screen.getByRole('button', { name: /^finish$/i }));
await user.click(await screen.findByRole('button', { name: /export markdown/i }));
expect(URL.createObjectURL).toHaveBeenCalled();
});
@@ -216,7 +216,7 @@ describe('Project header and sharing', () => {
const user = await loadChapter();
addChunk('Paul, a servant', 'Paul, a servant');
await screen.findByText('1 created');
await user.click(screen.getByRole('button', { name: /^export$/i }));
await user.click(screen.getByRole('button', { name: /^finish$/i }));
await user.click(await screen.findByRole('button', { name: /enable sharing/i }));
expect(await screen.findByDisplayValue(/\?share=tok123$/)).toBeInTheDocument();
});
@@ -310,12 +310,12 @@ describe('Chunk builder', () => {
});
// ---------------------------------------------------------------------------
// Stage bar (Chunks / Study / Outline / Script / Export)
// Stage bar (Chunks / Study / Outline / Script / Finish)
// ---------------------------------------------------------------------------
describe('Stage bar', () => {
test('later stages are disabled until a chunk exists', async () => {
await loadChapter();
for (const name of [/^study$/i, /^outline$/i, /^script$/i, /^export$/i]) {
for (const name of [/^study$/i, /^outline$/i, /^script$/i, /^finish$/i]) {
expect(screen.getByRole('button', { name })).toBeDisabled();
}
expect(screen.getByRole('button', { name: /^chunks$/i })).not.toBeDisabled();
@@ -419,12 +419,108 @@ describe('Study workspace', () => {
test('commentary: "Add to notes" quotes a paragraph with its citation', async () => {
const user = await studyOneChunk([['/api/c/matthew-henry/TIT/1.json', { chapter: { content: [{ type: 'verse', number: 1, content: ['Paul was an apostle.'] }] } }]]);
expect(screen.getByText('Observe first')).toBeInTheDocument(); // commentary waits for your own notes
await user.click(screen.getByRole('button', { name: /show it anyway/i }));
await user.click(await screen.findByRole('button', { name: /add to notes/i }));
expect(await screen.findByRole('status')).toHaveTextContent(/background notes/i);
await user.click(screen.getByRole('button', { name: /^more/i }));
expect(screen.getByLabelText(/background/i)).toHaveValue('Paul was an apostle. (Matthew Henry, Titus 1:1)');
});
test('commentary opens once you have written enough of your own Observe notes', async () => {
await studyOneChunk([['/api/c/matthew-henry/TIT/1.json', { chapter: { content: [{ type: 'verse', number: 1, content: ['Paul was an apostle.'] }] } }]]);
expect(screen.getByText('Observe first')).toBeInTheDocument();
fireEvent.change(screen.getByRole('textbox', { name: 'Observe' }), { target: { value: Array.from({ length: 15 }, (_, i) => `word${i}`).join(' ') } });
expect(screen.queryByText('Observe first')).not.toBeInTheDocument();
expect(await screen.findByRole('button', { name: /add to notes/i })).toBeInTheDocument();
});
test('either half of the right column can be folded, and the other always stays open', async () => {
const user = await studyOneChunk();
const notes = screen.getByRole('button', { name: /your notes/i });
const resources = screen.getByRole('button', { name: /commentary & resources/i });
expect(notes).toHaveAttribute('aria-expanded', 'true');
await user.click(notes);
expect(notes).toHaveAttribute('aria-expanded', 'false');
expect(resources).toHaveAttribute('aria-expanded', 'true');
await user.click(resources); // folding the last open half opens the other one
expect(resources).toHaveAttribute('aria-expanded', 'false');
expect(notes).toHaveAttribute('aria-expanded', 'true');
});
test('a long paste asks if it is from AI; "I wrote it" takes the tag back', async () => {
const user = await studyOneChunk();
const observe = screen.getByRole('textbox', { name: 'Observe' });
const pasted = Array.from({ length: 20 }, (_, i) => `pasted${i}`).join(' ');
fireEvent.paste(observe, { clipboardData: { getData: () => pasted } });
fireEvent.change(observe, { target: { value: pasted } });
expect(await screen.findByText(/is this from ai\?/i)).toBeInTheDocument();
expect(screen.getByText(/20 words here were pasted/i)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /i wrote it/i }));
expect(screen.queryByText(/is this from ai\?/i)).not.toBeInTheDocument();
expect(screen.queryByText(/were pasted/i)).not.toBeInTheDocument();
});
test('a short paste is not asked about', async () => {
await studyOneChunk();
const observe = screen.getByRole('textbox', { name: 'Observe' });
fireEvent.paste(observe, { clipboardData: { getData: () => 'just a few words' } });
expect(screen.queryByText(/is this from ai\?/i)).not.toBeInTheDocument();
});
test('a note keeps earlier versions that can be restored', async () => {
const user = await studyOneChunk();
const observe = screen.getByRole('textbox', { name: 'Observe' });
fireEvent.focus(observe);
fireEvent.change(observe, { target: { value: 'First thought about the passage.' } });
fireEvent.blur(observe);
fireEvent.focus(observe);
fireEvent.change(observe, { target: { value: 'Completely rewritten.' } });
fireEvent.blur(observe);
await user.click(screen.getByRole('button', { name: 'History' }));
const list = screen.getByRole('list', { name: /observe history/i });
expect(within(list).getByText(/first thought about the passage/i)).toBeInTheDocument();
await user.click(within(list).getAllByRole('button', { name: 'Restore' }).find((b) => !b.disabled));
expect(screen.getByRole('textbox', { name: 'Observe' })).toHaveValue('First thought about the passage.');
});
test('keyboard: 1 / 2 / 3 jump into a note, Esc leaves it, and V opens Compare', async () => {
const user = await studyOneChunk();
await user.keyboard('2');
expect(screen.getByRole('textbox', { name: 'Interpret' })).toHaveFocus();
await user.keyboard('{Escape}');
expect(screen.getByRole('textbox', { name: 'Interpret' })).not.toHaveFocus();
await user.keyboard('3');
expect(screen.getByRole('textbox', { name: 'Apply' })).toHaveFocus();
await user.keyboard('{Escape}');
await user.keyboard('v');
expect(screen.getByRole('dialog', { name: /compare/i })).toBeInTheDocument();
});
test('compare translations: the Observe / Interpret / Apply boxes edit the same notes', async () => {
const user = await studyOneChunk();
await user.click(screen.getByRole('button', { name: /compare…/i }));
await user.click(screen.getByRole('tab', { name: /translations/i }));
const dialog = screen.getByRole('dialog', { name: /compare/i });
expect(within(dialog).getByRole('textbox', { name: 'Interpret' })).toBeInTheDocument();
expect(within(dialog).getByRole('textbox', { name: 'Apply' })).toBeInTheDocument();
fireEvent.change(within(dialog).getByRole('textbox', { name: 'Observe' }), { target: { value: 'Written beside the translations.' } });
await user.click(within(dialog).getByRole('button', { name: /close/i }));
expect(screen.getByRole('textbox', { name: 'Observe' })).toHaveValue('Written beside the translations.');
});
test('reading list: add a book to the study, tick it off, and it is remembered with the study', async () => {
const user = await studyOneChunk();
await user.click(screen.getByRole('tab', { name: /reading list/i }));
await user.type(screen.getByRole('textbox', { name: /add to the reading list/i }), 'Carson on John{Enter}');
const tick = screen.getByRole('checkbox', { name: /read: carson on john/i });
expect(tick).not.toBeChecked();
await user.click(tick);
expect(screen.getByRole('checkbox', { name: /read: carson on john/i })).toBeChecked();
await user.click(screen.getByRole('tab', { name: /commentary/i }));
expect(screen.getByRole('tab', { name: /reading list/i })).toBeInTheDocument(); // none left to read, so no count
});
test('people & places: lists what is tagged in the chunk', async () => {
const user = await studyOneChunk([['/d/theographic/TIT/1.json', { chapter: { people: [{ id: 'paul_1', name: 'Paul', verses: [1] }, { id: 'x_2', name: 'Elsewhere', verses: [9] }], places: [], events: [] } }]]);
await user.click(screen.getByRole('tab', { name: /people & places/i }));
@@ -514,4 +610,3 @@ describe('Reading plan with more days than chapters', () => {
expect(screen.queryByText(/back to today.s reading/i)).not.toBeInTheDocument();
});
});
+1 -1
View File
@@ -26,7 +26,7 @@ describe('Back to the dashboard', () => {
await dashboard();
});
test.each(['Chunks', 'Study', 'Outline', 'Script', 'Export'])('the %s stage of a study', async (stage) => {
test.each(['Chunks', 'Study', 'Outline', 'Script', 'Finish'])('the %s stage of a study', async (stage) => {
const u = await loadChapter();
addChunk('Paul, a servant', 'In hope of eternal');
await screen.findByText('1 created');
+11
View File
@@ -89,3 +89,14 @@ export function mergeReadingPlans(local, incoming) {
activity: [...new Set([...(local.activity ?? []), ...(incoming.activity ?? [])])].sort(),
};
}
// When you last downloaded a backup on this device, so Home can say so (and nudge when it has been a while).
const LAST_BACKUP_KEY = 'last-backup-at';
export const markBackedUp = (now = Date.now()) => { try { localStorage.setItem(LAST_BACKUP_KEY, String(now)); } catch { /* storage unavailable */ } };
export const lastBackupAt = () => { try { return Number(localStorage.getItem(LAST_BACKUP_KEY)) || null; } catch { return null; } };
export function backupAge(at, now = Date.now()) {
if (!at) return { text: 'You haven’t downloaded a backup yet', stale: true };
const days = Math.floor((now - at) / 86400000);
if (days < 1) return { text: 'Backed up today', stale: false };
return { text: `Last backup ${days} day${days === 1 ? '' : 's'} ago`, stale: days >= 14 };
}
+10
View File
@@ -68,3 +68,13 @@ test('prayers are in the backup, deleted ones are not, and they survive validati
expect(checked.backup.prayers[0].text).toBe('Peace');
expect(validateBackup({ app: 'bible-study-project', version: 1, prayers: [{ id: 'x' }, 5, { id: 'y', text: ' ' }] }).backup.prayers).toEqual([]);
});
import { backupAge } from './backup.js';
test('the backup age says how long ago, and flags a long gap', () => {
const now = 1_000_000_000_000;
expect(backupAge(null, now)).toMatchObject({ stale: true });
expect(backupAge(now - 3600_000, now)).toEqual({ text: 'Backed up today', stale: false });
expect(backupAge(now - 5 * 86400000, now)).toEqual({ text: 'Last backup 5 days ago', stale: false });
expect(backupAge(now - 20 * 86400000, now).stale).toBe(true);
});
+56
View File
@@ -0,0 +1,56 @@
// Comparing translations of one chunk: which chapter(s) and verses to fetch, and the verses lined up by number.
import { getChunkVerseEntries } from './bibleUtils.jsx';
import { diffVerses } from './textDiff.js';
// -> [{ bookAbbrev, book, chapter, from, to }]: one entry per chapter the chunk covers (two when it spills over).
export function translationSpans(project, chapterIndex, chunk) {
const spans = [];
for (const v of getChunkVerseEntries(project, chapterIndex, chunk)) {
const last = spans[spans.length - 1];
if (last && last.chapter === v.chapter && last.book === v.book) { last.to = Math.max(last.to, v.number); last.from = Math.min(last.from, v.number); continue; }
const chapter = project.chapters.find((c) => c.book === v.book && String(c.chapter) === String(v.chapter));
spans.push({ bookAbbrev: chapter?.bookAbbrev, book: v.book, chapter: v.chapter, from: v.number, to: v.number });
}
return spans;
}
// byTranslation: { BSB: { verses: [{ number, text }] }, … } for the chapters in `spans`; `loaded` is keyed by
// `${chapter}` inside each translation. -> [{ key, label, texts: { BSB: 'text' | null } }] one row per verse, in order.
export function alignVerses(spans, byTranslation) {
const rows = [];
for (const span of spans) {
for (let n = span.from; n <= span.to; n += 1) {
const texts = {};
for (const [id, chapters] of Object.entries(byTranslation)) {
texts[id] = chapters?.[`${span.book}|${span.chapter}`]?.find((v) => v.number === n)?.text ?? null;
}
rows.push({ key: `${span.book}${span.chapter}:${n}`, noteKey: `${span.chapter}:${n}`, label: spans.length > 1 ? `${span.chapter}:${n}` : String(n), texts });
}
}
return rows;
}
// For one verse in several translations: which words in each text the others lack, measured against the first
// translation (and the first against the second). -> { BSB: [{ start, end }], NET: [...] }
export function differencesFor(texts, ids) {
const out = Object.fromEntries(ids.map((id) => [id, []]));
const present = ids.filter((id) => texts[id]);
if (present.length < 2) return out;
const [base, ...rest] = present;
out[base] = diffVerses(texts[base], texts[rest[0]]).a;
for (const id of rest) out[id] = diffVerses(texts[base], texts[id]).b;
return out;
}
// Cuts a text at the ranges -> [{ text, mark }] so a view can wrap the marked parts.
export function markRanges(text, ranges) {
const parts = [];
let at = 0;
for (const r of [...(ranges ?? [])].sort((a, b) => a.start - b.start)) {
if (r.start > at) parts.push({ text: text.slice(at, r.start), mark: false });
parts.push({ text: text.slice(r.start, r.end), mark: true });
at = r.end;
}
if (at < text.length) parts.push({ text: text.slice(at), mark: false });
return parts;
}
+45
View File
@@ -0,0 +1,45 @@
import { test, expect } from 'vitest';
import { translationSpans, alignVerses } from './compareTranslations.js';
const verses = (from, to) => Array.from({ length: to - from + 1 }, (_, i) => ({ number: from + i, text: `v${from + i}` }));
const project = { chapters: [
{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: verses(1, 36), chunks: [] },
{ book: 'John', bookAbbrev: 'JHN', chapter: '4', verses: verses(1, 54), chunks: [] },
] };
test('a chunk inside one chapter is a single span', () => {
expect(translationSpans(project, 0, { startVerse: 16, endVerse: 18, spilloverEndVerse: null }))
.toEqual([{ bookAbbrev: 'JHN', book: 'John', chapter: '3', from: 16, to: 18 }]);
});
test('a chunk that runs into the next chapter has a span for each', () => {
const spans = translationSpans(project, 0, { startVerse: 35, endVerse: 36, spilloverEndVerse: 2 });
expect(spans.map((s) => [s.chapter, s.from, s.to])).toEqual([['3', 35, 36], ['4', 1, 2]]);
});
test('verses are lined up by number, with null where a translation lacks one', () => {
const spans = [{ book: 'John', chapter: '3', from: 16, to: 17 }];
const rows = alignVerses(spans, {
BSB: { 'John|3': [{ number: 16, text: 'a' }, { number: 17, text: 'b' }] },
NET: { 'John|3': [{ number: 16, text: 'c' }] },
});
expect(rows.map((r) => r.label)).toEqual(['16', '17']);
expect(rows[0].texts).toEqual({ BSB: 'a', NET: 'c' });
expect(rows[1].texts).toEqual({ BSB: 'b', NET: null });
});
import { differencesFor, markRanges } from './compareTranslations.js';
test('differences are the words a translation has that the first one lacks', () => {
const d = differencesFor({ BSB: 'For God so loved the world', NET: 'For this is the way God loved the world', ESV: 'For God so loved the world' }, ['BSB', 'NET', 'ESV']);
expect(d.ESV).toEqual([]); // same words as the first
const text = 'For this is the way God loved the world';
expect(markRanges(text, d.NET).filter((p) => p.mark).map((p) => p.text.trim()).join(' ')).toContain('this is the way');
expect(differencesFor({ BSB: 'a b', NET: null }, ['BSB', 'NET']).BSB).toEqual([]); // nothing to compare against
});
test('markRanges splits a text into plain and marked pieces that rejoin to the original', () => {
const parts = markRanges('one two three four', [{ start: 4, end: 7 }, { start: 14, end: 18 }]);
expect(parts.map((p) => p.text).join('')).toBe('one two three four');
expect(parts.filter((p) => p.mark).map((p) => p.text)).toEqual(['two', 'four']);
});
+53
View File
@@ -0,0 +1,53 @@
// Daily five minutes: one short prompt a day, on a passage from your studies where that kind of note is thinnest.
// What you write is added to the matching note (Observe, Interpret or Apply) and counts as your own words.
import { formatChunkReference, getChunkVerseEntries } from './bibleUtils.jsx';
import { ownWords } from './ownWork.js';
export const MINUTES = 5;
export const PROMPTS = [
{ field: 'observation', text: 'Read the passage slowly. Write three things you notice: a repeated word, who is speaking, something that surprises you.' },
{ field: 'interpretation', text: 'What would the first hearers have understood? Write one or two sentences about the situation they were in.' },
{ field: 'application', text: 'Write one thing you will believe differently, and one thing you will do, because of this passage.' },
{ field: 'observation', text: 'What does this passage say about God? Say it using only what the text itself says.' },
{ field: 'interpretation', text: 'How does this passage connect to what comes just before or after it? Write how it fits.' },
{ field: 'application', text: 'Who is one person this passage speaks to? Write what you would say to them.' },
{ field: 'observation', text: 'Read it aloud. Which word or phrase stands out most? Write down why.' },
];
export const FIELD_NAMES = { observation: 'Observe', interpretation: 'Interpret', application: 'Apply' };
const dayNumber = (now) => { const d = new Date(now); return Math.floor(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / 86400000); };
export const promptFor = (now = Date.now()) => PROMPTS[dayNumber(now) % PROMPTS.length];
const sameDay = (a, b) => new Date(a).toDateString() === new Date(b).toDateString();
// Did you already do your five minutes today?
export function doneToday(projects, now = Date.now()) {
return (projects ?? []).some((p) => (p?.chapters ?? []).some((ch) => (ch.chunks ?? []).some((c) => (c.fiveMinutes ?? []).some((f) => sameDay(f.at, now)))));
}
// Today's prompt and the passage to use it on: the chunk whose notes for that prompt have the fewest words of
// your own (oldest first on a tie). null when there are no studies, or today's five minutes are done.
export function pickDaily(projects, now = Date.now()) {
if (doneToday(projects, now)) return null;
const prompt = promptFor(now);
const all = (projects ?? []).flatMap((project) => (project?.chapters ?? []).flatMap((chapter, chapterIndex) => (chapter.chunks ?? []).map((chunk) => ({ project, chapterIndex, chunk }))));
const [best] = all.sort((a, b) => ownWords(a.chunk, prompt.field) - ownWords(b.chunk, prompt.field) || (a.chunk.editedAt ?? 0) - (b.chunk.editedAt ?? 0));
if (!best) return null;
return {
prompt, projectId: best.project.id, projectTitle: best.project.title, chunk: best.chunk,
reference: formatChunkReference(best.project, best.chapterIndex, best.chunk, '–'),
verses: getChunkVerseEntries(best.project, best.chapterIndex, best.chunk).map((v) => ({ number: v.number, text: v.text })),
};
}
const marker = (now) => `— five minutes, ${new Date(now).toLocaleDateString([], { month: 'short', day: 'numeric' })} —`;
// The patch that adds what you wrote to the chunk (below what's already there) and records the session.
export function dailyPatch(chunk, field, text, now = Date.now()) {
const added = text.trim();
const have = (chunk[field] ?? '').replace(/\s+$/, '');
return {
[field]: have ? `${have}\n\n${marker(now)}\n${added}` : added,
fiveMinutes: [...(chunk.fiveMinutes ?? []), { at: now, field }],
};
}
+42
View File
@@ -0,0 +1,42 @@
import { test, expect } from 'vitest';
import { PROMPTS, dailyPatch, doneToday, pickDaily, promptFor } from './dailyFive.js';
const NOW = new Date('2026-10-14T12:00:00').getTime();
const DAY = 86400000;
const text = (n) => Array.from({ length: n }, (_, i) => `w${i}`).join(' ');
const chunk = (id, extra = {}) => ({ id, startVerse: 1, endVerse: 2, observation: '', interpretation: '', application: '', editedAt: 1, ...extra });
const study = (chunks) => ({ id: 'p', title: 'John', translation: 'BSB', chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: [{ number: 1, text: 'a' }, { number: 2, text: 'b' }], chunks }] });
test('the prompt changes each day and cycles through all of them', () => {
const seen = new Set(Array.from({ length: PROMPTS.length }, (_, i) => promptFor(NOW + i * DAY).text));
expect(seen.size).toBe(PROMPTS.length);
expect(promptFor(NOW)).toBe(promptFor(NOW + 3600_000));
});
test('the passage chosen is the one with the fewest words of your own for that prompt', () => {
const field = promptFor(NOW).field;
const thick = chunk('thick', { [field]: text(80) });
const thin = chunk('thin', { [field]: text(3) });
const pasted = text(60);
const fake = chunk('fake', { [field]: pasted, aiPastes: { [field]: [pasted] } }); // pasted words count for nothing
const pick = pickDaily([study([thick, thin, fake])], NOW);
expect(pick.chunk.id).toBe('fake');
expect(pick.reference).toContain('John');
expect(pick.verses).toHaveLength(2);
expect(pickDaily([], NOW)).toBeNull();
});
test('once today is done, nothing more is offered until tomorrow', () => {
const done = chunk('a', { fiveMinutes: [{ at: NOW - 3600_000, field: 'observation' }] });
expect(doneToday([study([done])], NOW)).toBe(true);
expect(pickDaily([study([done, chunk('b')])], NOW)).toBeNull();
expect(pickDaily([study([done, chunk('b')])], NOW + DAY)).not.toBeNull();
});
test('what you write is added below your existing note and the session is recorded', () => {
const empty = dailyPatch(chunk('a'), 'observation', ' Three things. ', NOW);
expect(empty.observation).toBe('Three things.');
expect(empty.fiveMinutes).toEqual([{ at: NOW, field: 'observation' }]);
const more = dailyPatch(chunk('a', { observation: 'Earlier.\n' }), 'observation', 'Later.', NOW);
expect(more.observation).toMatch(/^Earlier\.\n\n— five minutes, .+ —\nLater\.$/);
});
+41
View File
@@ -0,0 +1,41 @@
// Used for the home card's verse of the day when IQ Bible can't be reached: one verse a day, the same for
// everyone, stepping through this list by the day of the year. [book USFM, chapter, verse].
export const FALLBACK_VERSES = [
['GEN', 1, 1], ['GEN', 1, 27], ['GEN', 12, 2], ['GEN', 28, 15], ['GEN', 50, 20],
['EXO', 14, 14], ['EXO', 15, 2], ['EXO', 33, 14], ['NUM', 6, 24], ['DEU', 6, 5],
['DEU', 31, 6], ['JOS', 1, 9], ['1SA', 16, 7], ['2CH', 7, 14], ['NEH', 8, 10],
['JOB', 19, 25], ['PSA', 1, 2], ['PSA', 16, 11], ['PSA', 19, 14], ['PSA', 23, 1],
['PSA', 23, 4], ['PSA', 27, 1], ['PSA', 27, 14], ['PSA', 28, 7], ['PSA', 30, 5],
['PSA', 34, 8], ['PSA', 34, 18], ['PSA', 37, 4], ['PSA', 46, 1], ['PSA', 46, 10],
['PSA', 51, 10], ['PSA', 55, 22], ['PSA', 62, 1], ['PSA', 91, 1], ['PSA', 100, 4],
['PSA', 103, 2], ['PSA', 118, 24], ['PSA', 119, 11], ['PSA', 119, 105], ['PSA', 121, 1],
['PSA', 121, 2], ['PSA', 139, 14], ['PSA', 145, 18], ['PRO', 3, 5], ['PRO', 3, 6],
['PRO', 4, 23], ['PRO', 16, 3], ['PRO', 18, 10], ['PRO', 22, 6], ['ECC', 3, 1],
['ISA', 26, 3], ['ISA', 40, 8], ['ISA', 40, 31], ['ISA', 41, 10], ['ISA', 43, 2],
['ISA', 53, 5], ['ISA', 55, 8], ['JER', 17, 7], ['JER', 29, 11], ['JER', 33, 3],
['LAM', 3, 22], ['EZK', 36, 26], ['DAN', 3, 17], ['MIC', 6, 8], ['HAB', 3, 17],
['ZEP', 3, 17], ['MAL', 3, 6], ['MAT', 5, 3], ['MAT', 5, 14], ['MAT', 5, 16],
['MAT', 6, 33], ['MAT', 7, 7], ['MAT', 11, 28], ['MAT', 19, 26], ['MAT', 22, 37],
['MAT', 28, 19], ['MAT', 28, 20], ['MRK', 10, 45], ['MRK', 12, 30], ['LUK', 1, 37],
['LUK', 6, 31], ['LUK', 11, 9], ['LUK', 19, 10], ['JHN', 1, 1], ['JHN', 1, 14],
['JHN', 3, 16], ['JHN', 6, 35], ['JHN', 8, 12], ['JHN', 8, 32], ['JHN', 10, 10],
['JHN', 11, 25], ['JHN', 13, 34], ['JHN', 14, 6], ['JHN', 14, 27], ['JHN', 15, 5],
['JHN', 16, 33], ['ACT', 1, 8], ['ACT', 4, 12], ['ROM', 1, 16], ['ROM', 3, 23],
['ROM', 5, 8], ['ROM', 6, 23], ['ROM', 8, 1], ['ROM', 8, 28], ['ROM', 8, 31],
['ROM', 8, 38], ['ROM', 10, 9], ['ROM', 12, 2], ['ROM', 15, 13], ['1CO', 10, 13],
['1CO', 13, 4], ['1CO', 13, 13], ['1CO', 15, 58], ['2CO', 5, 17], ['2CO', 12, 9],
['GAL', 2, 20], ['GAL', 5, 22], ['GAL', 6, 9], ['EPH', 2, 8], ['EPH', 2, 10],
['EPH', 3, 20], ['EPH', 4, 32], ['EPH', 6, 10], ['PHP', 1, 6], ['PHP', 4, 4],
['PHP', 4, 6], ['PHP', 4, 7], ['PHP', 4, 13], ['COL', 3, 2], ['COL', 3, 23],
['1TH', 5, 16], ['2TI', 1, 7], ['2TI', 3, 16], ['TIT', 3, 5], ['HEB', 4, 12],
['HEB', 4, 16], ['HEB', 11, 1], ['HEB', 11, 6], ['HEB', 12, 1], ['HEB', 13, 8],
['JAS', 1, 5], ['JAS', 1, 22], ['JAS', 4, 8], ['1PE', 5, 7], ['2PE', 3, 9],
['1JN', 1, 9], ['1JN', 4, 8], ['1JN', 4, 19], ['REV', 3, 20], ['REV', 21, 4],
];
// The verse for a calendar day (UTC, like IQ Bible's own pick, so everyone sees the same one).
export function fallbackVerseFor(date = new Date()) {
const day = Math.floor((Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()) - Date.UTC(date.getUTCFullYear(), 0, 0)) / 86400000);
const [book_usfm, chapter_number, verse_number] = FALLBACK_VERSES[day % FALLBACK_VERSES.length];
return { book_usfm, chapter_number, verse_number };
}
+16 -7
View File
@@ -30,16 +30,24 @@ export function questionsFor(chunk) {
return fallback.length ? { kind: 'think', items: fallback } : { kind: 'none', items: [] };
}
function sessionHtml(project, session, { answerLines }) {
// layout: 'standard' (everything), 'questions' (passage + questions only, one page), 'large' (big print), 'notes' (passage and a blank page to write).
export const HANDOUT_LAYOUTS = [
{ id: 'standard', label: 'Standard: passage, outline, questions' },
{ id: 'questions', label: 'Questions only: passage and discussion questions' },
{ id: 'large', label: 'Large print: passage and questions' },
{ id: 'notes', label: 'Note-taking: passage and lots of lines' },
];
function sessionHtml(project, session, { answerLines, layout = 'standard' }) {
const { chunk, chapterIndex, number, reference } = session;
const series = (project.series ?? '').trim();
const title = (chunk.episodeTitle ?? '').trim();
const num = (chunk.episodeNumber ?? '').toString().trim() || number;
const verses = getChunkVerseEntries(project, chapterIndex, chunk);
const outline = flatten(outlineForSession(project, chunk));
const q = questionsFor(chunk);
const outline = layout === 'standard' ? flatten(outlineForSession(project, chunk)) : [];
const q = layout === 'notes' ? { kind: 'none', items: [] } : questionsFor(chunk);
const blank = (n) => Array.from({ length: n }, () => '<div class="rule"></div>').join('');
return `<section class="page">
return `<section class="page${layout === 'standard' ? '' : ` ${layout}`}">
<header>
<p class="kicker">${esc(series || project.title)}</p>
<h1>Session ${esc(num)}${title ? `: ${esc(title)}` : ''}</h1>
@@ -49,13 +57,13 @@ function sessionHtml(project, session, { answerLines }) {
${verses.length ? `<div class="passage">${verses.map((v) => `<p><sup>${esc(v.number)}</sup> ${esc(v.text)}</p>`).join('')}</div>` : ''}
${outline.length ? `<h2>Outline</h2><ul class="outline">${outline.map(({ node, depth, label }) => `<li style="margin-left:${depth * 1.2}em"><b>${esc(label)}</b> ${esc(node.text || '')}${node.ref ? ` <i>(${esc(node.ref)})</i>` : ''}</li>`).join('')}</ul>` : ''}
${q.items.length ? `<h2>${q.kind === 'questions' ? 'Discussion questions' : 'Think about it'}</h2><ol class="questions">${q.items.map((item) => `<li>${esc(item)}${answerLines ? blank(2) : ''}</li>`).join('')}</ol>` : ''}
${answerLines ? `<h2>Notes</h2>${blank(q.items.length ? 4 : 10)}` : ''}
${answerLines || layout === 'notes' ? `<h2>Notes</h2>${blank(layout === 'notes' ? 16 : q.items.length ? 4 : 10)}` : ''}
<footer>${esc(project.title)}</footer>
</section>`;
}
// ids: the chunk ids to include, or null for every session
export function buildHandoutHtml(project, ids = null, { answerLines = true } = {}) {
export function buildHandoutHtml(project, ids = null, { answerLines = true, layout = 'standard' } = {}) {
const sessions = sessionsFor(project).filter((s) => !ids || ids.includes(s.chunk.id));
return `<!doctype html><html><head><meta charset="utf-8"><title>${esc(project.title)} — handout</title><style>
@page { margin: 18mm 16mm; }
@@ -71,8 +79,9 @@ export function buildHandoutHtml(project, ids = null, { answerLines = true } = {
.outline { list-style: none; padding: 0; margin: 0; } .outline li { margin-bottom: .25em; } .outline b { color: #a84e27; }
.questions { padding-left: 1.4em; } .questions li { margin-bottom: .7em; }
.rule { border-bottom: 1px solid #c9bfa8; height: 1.7em; }
.large { font-size: 17pt; line-height: 1.55; } .large h1 { font-size: 30pt; } .large .rule { height: 2.2em; }
footer { position: absolute; bottom: 0; left: 0; right: 0; text-align: center; color: #9b9484; font: 9pt -apple-system, Helvetica, Arial, sans-serif; }
</style></head><body>${sessions.map((s) => sessionHtml(project, s, { answerLines })).join('')}</body></html>`;
</style></head><body>${sessions.map((s) => sessionHtml(project, s, { answerLines, layout })).join('')}</body></html>`;
}
export function printHandout(project, ids = null, options) {
+16
View File
@@ -46,3 +46,19 @@ test('one session only, and no writing lines when switched off', () => {
expect(html).toContain('Session 2');
expect(html).not.toContain('class="rule"');
});
import { buildHandoutHtml as buildLayoutHtml } from './handout.js';
test('layouts change what the handout prints', () => {
const project = { id: 'p', title: 'T', translation: 'BSB', outline: [], chapters: [{ book: 'John', bookAbbrev: 'Jhn', chapter: '3', verses: [{ number: 16, text: 'For God so loved.' }], chunks: [{ id: 'c', startVerse: 16, endVerse: 16, discussionQuestions: 'Why did he give?', application: 'Trust him.', outline: [{ id: 'n', text: 'Point one', children: [] }] }] }] };
const standard = buildLayoutHtml(project);
const questions = buildLayoutHtml(project, null, { layout: 'questions' });
const notes = buildLayoutHtml(project, null, { layout: 'notes' });
const large = buildLayoutHtml(project, null, { layout: 'large' });
expect(standard).toContain('Point one');
expect(questions).not.toContain('Point one');
expect(questions).toContain('Why did he give?');
expect(notes).not.toContain('Why did he give?');
expect(notes.match(/class="rule"/g).length).toBeGreaterThanOrEqual(16);
expect(large).toContain('class="page large"');
});
+90
View File
@@ -0,0 +1,90 @@
// Bring notes written elsewhere (a Word file or Markdown from another app) into a study. A passage heading starts
// each section ("## John 3:16–18"), and labelled lines or sub-headings ("Observe: …", "### Interpret") hold the
// notes. Sections are matched to your chunks by passage. Nothing already written is overwritten.
import { parsePassageRef } from './passageRef.js';
import { withPaste } from './ownWork.js';
const LABELS = [
[/^observ(?:e|ation|ations)$/i, 'observation'],
[/^interpret(?:ation)?$/i, 'interpretation'],
[/^appl(?:y|ication)$/i, 'application'],
[/^(?:background|notes?|general notes)$/i, 'generalNotes'],
[/^(?:discussion )?questions$/i, 'discussionQuestions'],
];
const FIELD_NAMES = { observation: 'Observe', interpretation: 'Interpret', application: 'Apply', generalNotes: 'Background', discussionQuestions: 'Questions' };
export const importFieldName = (f) => FIELD_NAMES[f] ?? f;
const clean = (line) => line.replace(/^\s*#+\s*/, '').replace(/\*\*|__/g, '').trim();
const labelOf = (word) => LABELS.find(([re]) => re.test(word.trim()))?.[1] ?? null;
// A heading that is just a passage ("John 3:16-18", "Session 2: Romans 8:1–4") -> { book, chapter, verse, text } or null.
export function passageHeading(line) {
const text = clean(line).replace(/^(?:session|lesson|week|episode|part)\s*\d+\s*[:.\-–—]\s*/i, '');
const m = text.match(/^((?:[1-3]\s*)?[A-Za-z][A-Za-z .]*?)\s+(\d{1,3})(?:\s*:\s*(\d{1,3}))?(?:\s*[-–—]\s*\d{1,3}(?:\s*:\s*\d{1,3})?)?\s*$/);
if (!m || text.length > 60) return null;
const ref = parsePassageRef(`${m[1]} ${m[2]}${m[3] ? `:${m[3]}` : ''}`)[0];
return ref ? { book: ref.book, chapter: ref.chapter, verse: m[3] ? Number(m[3]) : null, text } : null;
}
// -> [{ heading, ref, observation?, interpretation?, application?, generalNotes?, discussionQuestions? }]
export function parseNotesText(raw) {
const sections = [];
let current = null;
let field = null;
const append = (value) => {
if (!current || !field) return;
current[field] = current[field] ? `${current[field]}\n${value}` : value;
};
for (const line of String(raw ?? '').replace(/\r/g, '').split('\n')) {
const heading = passageHeading(line);
if (heading) { current = { heading: heading.text, ref: heading }; sections.push(current); field = null; continue; }
if (!current) continue;
const stripped = clean(line).replace(/^[-*•]\s+/, '');
const labelled = stripped.match(/^([A-Za-z ]{3,22}?)\s*[:\-–]\s*(.*)$/);
const bare = /^\s*(?:#+\s|\*\*|__)/.test(line) ? labelOf(stripped.replace(/[:\-–]\s*$/, '')) : null; // "### Observe", "**Observe**"
if (labelled && labelOf(labelled[1])) { field = labelOf(labelled[1]); current[field] = current[field] ?? ''; if (labelled[2].trim()) append(labelled[2].trim()); continue; }
if (bare) { field = bare; current[field] = current[field] ?? ''; continue; }
if (field) append(line.trim());
}
return sections.map((s) => Object.fromEntries(Object.entries(s).map(([k, v]) => [k, typeof v === 'string' ? v.replace(/^\n+|\n+$/g, '').trim() : v])))
.filter((s) => Object.keys(FIELD_NAMES).some((f) => s[f]));
}
// Which chunk a section belongs to: the one whose verses include the heading's verse (or the first in that chapter).
export function matchChunk(project, ref) {
for (const chapter of project?.chapters ?? []) {
if (chapter.bookAbbrev !== ref.book || Number(chapter.chapter) !== ref.chapter) continue;
const chunks = chapter.chunks ?? [];
const hit = ref.verse == null ? chunks[0] : chunks.find((c) => ref.verse >= c.startVerse && ref.verse <= c.endVerse);
if (hit) return hit;
}
return null;
}
const MARKER = '— imported —';
// -> { project, matched: [{ heading, fields }], unmatched: [heading] }. Where a note already has text, the imported
// text is added below a marker instead of replacing it. `own: false` records the text as pasted, not your own work.
export function applyImportedNotes(project, sections, { own = true, now = Date.now() } = {}) {
const patches = new Map(); // chunk id -> merged chunk
const matched = [];
const unmatched = [];
for (const section of sections) {
const chunk = matchChunk(project, section.ref);
if (!chunk) { unmatched.push(section.heading); continue; }
const next = { ...(patches.get(chunk.id) ?? chunk) };
const fields = [];
for (const f of Object.keys(FIELD_NAMES)) {
const incoming = section[f];
if (!incoming) continue;
const have = (next[f] ?? '').trim();
if (have.includes(incoming)) continue;
next[f] = have ? `${next[f].replace(/\s+$/, '')}\n\n${MARKER}\n${incoming}` : incoming;
if (!own && ['observation', 'interpretation', 'application'].includes(f)) next.aiPastes = withPaste({ aiPastes: next.aiPastes }, f, incoming) ?? { ...(next.aiPastes ?? {}), [f]: [...(next.aiPastes?.[f] ?? []), incoming] };
fields.push(importFieldName(f));
}
if (fields.length) { next.editedAt = now; patches.set(chunk.id, next); matched.push({ heading: section.heading, fields }); }
}
const out = { ...project, chapters: project.chapters.map((ch) => ({ ...ch, chunks: ch.chunks.map((c) => patches.get(c.id) ?? c) })) };
return { project: out, matched, unmatched };
}
+60
View File
@@ -0,0 +1,60 @@
import { test, expect } from 'vitest';
import { parseNotesText, passageHeading, applyImportedNotes, matchChunk } from './importNotes.js';
import { ownWords } from './ownWork.js';
const SAMPLE = `# My John study
## John 3:16-18
Observe: God loved the world.
He gave his Son.
Interpret: Love is shown by giving.
### Apply
Trust him this week.
## Session 2: John 3:19–21
**Observation:** Light and darkness.
Questions:
- Why do people love darkness?
`;
test('headings that are a passage are recognised, others are not', () => {
expect(passageHeading('## John 3:16-18')).toMatchObject({ book: 'JHN', chapter: 3, verse: 16 });
expect(passageHeading('Session 2: John 3:19–21')).toMatchObject({ chapter: 3, verse: 19 });
expect(passageHeading('Romans 8')).toMatchObject({ book: 'ROM', chapter: 8, verse: null });
expect(passageHeading('# My John study')).toBeNull();
expect(passageHeading('Observe: God loved')).toBeNull();
});
test('sections and their labelled notes are read from Markdown', () => {
const sections = parseNotesText(SAMPLE);
expect(sections).toHaveLength(2);
expect(sections[0]).toMatchObject({ observation: 'God loved the world.\nHe gave his Son.', interpretation: 'Love is shown by giving.', application: 'Trust him this week.' });
expect(sections[1].observation).toBe('Light and darkness.');
expect(sections[1].discussionQuestions).toContain('Why do people love darkness?');
});
const project = () => ({ id: 'p', title: 'T', chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: [], chunks: [
{ id: 'a', startVerse: 16, endVerse: 18, observation: '', interpretation: 'Mine already.' },
{ id: 'b', startVerse: 19, endVerse: 21, observation: '' },
] }] });
test('notes land in the chunk that holds the verse, and nothing written is overwritten', () => {
const sections = [...parseNotesText(SAMPLE), { heading: 'Romans 8', ref: { book: 'ROM', chapter: 8, verse: null }, observation: 'x' }];
const { project: out, matched, unmatched } = applyImportedNotes(project(), sections, { now: 5 });
const a = out.chapters[0].chunks[0];
expect(a.observation).toContain('God loved the world.');
expect(a.interpretation).toBe('Mine already.\n\n— imported —\nLove is shown by giving.');
expect(out.chapters[0].chunks[1].observation).toBe('Light and darkness.');
expect(matched).toHaveLength(2);
expect(unmatched).toEqual(['Romans 8']);
expect(matchChunk(project(), { book: 'JHN', chapter: 3, verse: 20 }).id).toBe('b');
});
test('importing as "not mine" records the text as pasted, so it is not counted as your own work', () => {
const long = Array.from({ length: 30 }, (_, i) => `w${i}`).join(' ');
const sections = [{ heading: 'John 3:16', ref: { book: 'JHN', chapter: 3, verse: 16 }, observation: long }];
const mine = applyImportedNotes(project(), sections, { own: true }).project.chapters[0].chunks[0];
const notMine = applyImportedNotes(project(), sections, { own: false }).project.chapters[0].chunks[0];
expect(ownWords(mine, 'observation')).toBe(30);
expect(ownWords(notMine, 'observation')).toBe(0);
});
+79
View File
@@ -0,0 +1,79 @@
// Looks across every study at once: this week's own work, tags as themes, the original-language words you
// keep returning to, what slipped in recalls, and follow-ups still open. `projects` are full study projects.
import { formatChunkReference } from './bibleUtils.jsx';
import { NOTE_FIELDS, ownShare, ownWords } from './ownWork.js';
import { WEEK_MS } from './recall.js';
const lines = (text) => String(text ?? '').split('\n').map((l) => l.replace(/^\s*(?:[-*•–]|\d{1,2}[.)])\s+/, '').trim()).filter(Boolean);
// Every chunk with where it lives.
function everyChunk(projects) {
return (projects ?? []).flatMap((project) => (project?.chapters ?? []).flatMap((chapter, chapterIndex) => (chapter.chunks ?? []).map((chunk) => ({
project, chunk, projectId: project.id, projectTitle: project.title, chunkId: chunk.id,
reference: formatChunkReference(project, chapterIndex, chunk, '–'),
}))));
}
// Chunks edited in the last 7 days: how many, your own words in them, the share that is yours, and recalls done.
export function weeklySummary(projects, now = Date.now()) {
const all = everyChunk(projects);
const recent = all.filter(({ chunk }) => now - (chunk.editedAt ?? 0) < WEEK_MS && (chunk.editedAt ?? 0) > 0);
const ownWordsTotal = recent.reduce((n, { chunk }) => n + NOTE_FIELDS.reduce((m, f) => m + ownWords(chunk, f), 0), 0);
const shares = recent.map(({ chunk }) => ownShare(chunk)).filter((s) => s !== null);
return {
chunksTouched: recent.length,
ownWords: ownWordsTotal,
ownShare: shares.length ? Math.round(shares.reduce((a, b) => a + b, 0) / shares.length) : null,
recalls: all.reduce((n, { chunk }) => n + (chunk.recalls ?? []).filter((r) => now - (r.at ?? 0) < WEEK_MS).length, 0),
fiveMinutes: all.reduce((n, { chunk }) => n + (chunk.fiveMinutes ?? []).filter((f) => now - (f.at ?? 0) < WEEK_MS).length, 0),
teachBacks: recent.filter(({ chunk }) => (chunk.teachBack ?? '').trim()).length,
};
}
const snippet = (chunk) => {
const raw = (chunk.observation || chunk.interpretation || chunk.application || '').replace(/\s+/g, ' ').trim();
return raw.length > 110 ? `${raw.slice(0, 110)}…` : raw;
};
// Tags across studies: [{ tag, count, items: [{ projectId, projectTitle, chunkId, reference, snippet }] }], most used first.
export function themeIndex(projects) {
const map = new Map();
for (const entry of everyChunk(projects)) {
for (const tag of entry.chunk.tags ?? []) {
const key = tag.trim().toLowerCase();
if (!key) continue;
if (!map.has(key)) map.set(key, { tag: tag.trim(), items: [] });
map.get(key).items.push({ projectId: entry.projectId, projectTitle: entry.projectTitle, chunkId: entry.chunkId, reference: entry.reference, snippet: snippet(entry.chunk) });
}
}
return [...map.values()].map((t) => ({ ...t, count: t.items.length })).sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
}
// Original-language words studied in more than one place (or all, with minCount 1): most often first.
export function wordIndex(projects, { minCount = 2 } = {}) {
const map = new Map();
for (const entry of everyChunk(projects)) {
for (const w of entry.chunk.greekWords ?? []) {
const strong = (w.strongNumber ?? '').toUpperCase();
const key = strong || (w.lexeme ?? w.query ?? '').toLowerCase();
if (!key) continue;
if (!map.has(key)) map.set(key, { key, strong, lexeme: w.lexeme || w.query || key, gloss: w.englishGloss || w.shortDefinition || '', transliteration: w.transliteration ?? '', places: [] });
map.get(key).places.push({ projectId: entry.projectId, projectTitle: entry.projectTitle, chunkId: entry.chunkId, reference: entry.reference });
}
}
return [...map.values()].map((w) => ({ ...w, count: w.places.length })).filter((w) => w.count >= minCount).sort((a, b) => b.count - a.count || a.lexeme.localeCompare(b.lexeme));
}
// What you said slipped in your recalls, newest first: [{ at, reference, projectId, chunkId, items: [line] }].
export function slippedList(projects) {
return everyChunk(projects).flatMap((e) => (e.chunk.recalls ?? [])
.map((r) => ({ at: r.at ?? 0, reference: e.reference, projectId: e.projectId, projectTitle: e.projectTitle, chunkId: e.chunkId, items: lines(r.gaps) }))
.filter((r) => r.items.length)).sort((a, b) => b.at - a.at);
}
// Follow-ups across studies: open ones first (then done), newest chunk first.
export function followUpList(projects) {
return everyChunk(projects).flatMap((e) => (e.chunk.followUps ?? []).map((f) => ({
id: f.id, text: f.text, done: !!f.done, reference: e.reference, projectId: e.projectId, projectTitle: e.projectTitle, chunkId: e.chunkId, editedAt: e.chunk.editedAt ?? 0,
}))).sort((a, b) => Number(a.done) - Number(b.done) || b.editedAt - a.editedAt);
}
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff. Show more