+ );
+}
diff --git a/src/components/CoTeachers.jsx b/src/components/CoTeachers.jsx
index 69336d5..d8ff556 100644
--- a/src/components/CoTeachers.jsx
+++ b/src/components/CoTeachers.jsx
@@ -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';
diff --git a/src/components/HandoutExport.jsx b/src/components/HandoutExport.jsx
index 6abc32b..46f09be 100644
--- a/src/components/HandoutExport.jsx
+++ b/src/components/HandoutExport.jsx
@@ -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 (
diff --git a/src/components/ImportNotes.jsx b/src/components/ImportNotes.jsx
new file mode 100644
index 0000000..fa5d3d3
--- /dev/null
+++ b/src/components/ImportNotes.jsx
@@ -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 (
+
+
Import notes
+
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.
Send the passcode separately from the link. Anyone with both can read it until it expires or you turn it off.
+
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.' : ''}
);
}
diff --git a/src/components/OfflineBadge.jsx b/src/components/OfflineBadge.jsx
new file mode 100644
index 0000000..293a159
--- /dev/null
+++ b/src/components/OfflineBadge.jsx
@@ -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
;
+ if (status.state === 'ready') return
✓ Works offline
;
+ if (status.state === 'licensed') {
+ return
Notes work offline; {status.translation} text needs a connection
+ Needs a connection for {status.missing.map((b) => b.name).join(', ')}
+
+
+ );
+}
diff --git a/src/components/PresentMode.jsx b/src/components/PresentMode.jsx
index 1e2008e..f40ae32 100644
--- a/src/components/PresentMode.jsx
+++ b/src/components/PresentMode.jsx
@@ -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 && (
+
+ {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.'}
+
Speaker notes are showing on this screen (N to hide)
}
+ >
+ ) :
There’s no outline to present yet. Build one on the Outline page.
}
+
+ )}
{total ? `${total} min planned` : 'Set minutes on points to see your pace'} · arrows or space to move
diff --git a/src/components/PresentMode.test.jsx b/src/components/PresentMode.test.jsx
index e7b8232..dc128fa 100644
--- a/src/components/PresentMode.test.jsx
+++ b/src/components/PresentMode.test.jsx
@@ -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( {}} />);
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( {}} />);
+ 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( {}} />);
+ 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( {}} />);
+ 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( {}} />);
+ 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( {}} />);
+ const channel = FakeChannel.instances[0];
+ unmount();
+ expect(channel.sent.at(-1)).toEqual({ type: 'end' });
+});
diff --git a/src/components/SessionSchedule.jsx b/src/components/SessionSchedule.jsx
index 2f792e3..9b6d527 100644
--- a/src/components/SessionSchedule.jsx
+++ b/src/components/SessionSchedule.jsx
@@ -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';
diff --git a/src/components/ShortcutsHelp.jsx b/src/components/ShortcutsHelp.jsx
index 3037830..3c61e7c 100644
--- a/src/components/ShortcutsHelp.jsx
+++ b/src/components/ShortcutsHelp.jsx
@@ -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', 'Commentary / Word study / Cross-refs / People & places'],
+ ['V', 'Compare passages or translations'],
],
};
diff --git a/src/components/SlidesExport.jsx b/src/components/SlidesExport.jsx
new file mode 100644
index 0000000..019b870
--- /dev/null
+++ b/src/components/SlidesExport.jsx
@@ -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 (
+
+
Slides
+
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.
}
+
+ );
+}
diff --git a/src/components/StepFace.jsx b/src/components/StepFace.jsx
new file mode 100644
index 0000000..f68e434
--- /dev/null
+++ b/src/components/StepFace.jsx
@@ -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 (
+
+ );
+}
diff --git a/src/components/VersionHistory.jsx b/src/components/VersionHistory.jsx
index da4883b..905000c 100644
--- a/src/components/VersionHistory.jsx
+++ b/src/components/VersionHistory.jsx
@@ -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';
diff --git a/src/components/WeeklyRecall.jsx b/src/components/WeeklyRecall.jsx
index 7a30379..2d38804 100644
--- a/src/components/WeeklyRecall.jsx
+++ b/src/components/WeeklyRecall.jsx
@@ -16,6 +16,7 @@ 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],
@@ -24,7 +25,7 @@ export default function WeeklyRecall() {
if (!pick) return null;
const finish = (rating) => {
- saveChunkRecall(pick.projectId, pick.chunk.id, { at: Date.now(), text: text.trim(), rating });
+ saveChunkRecall(pick.projectId, pick.chunk.id, { at: Date.now(), text: text.trim(), rating, gaps: gaps.trim() });
setStep('done');
};
@@ -63,6 +64,10 @@ export default function WeeklyRecall() {
))}
+ What did you miss or get wrong? (one per line; collected in Insights)
+
How did you do?
{RATE.map((r) => (
diff --git a/src/components/settings/DataSection.jsx b/src/components/settings/DataSection.jsx
index c9f6046..b1ea71b 100644
--- a/src/components/settings/DataSection.jsx
+++ b/src/components/settings/DataSection.jsx
@@ -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`);
};
diff --git a/src/components/study/CompareSheet.jsx b/src/components/study/CompareSheet.jsx
new file mode 100644
index 0000000..47476fd
--- /dev/null
+++ b/src/components/study/CompareSheet.jsx
@@ -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
+ );
+}
+
+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 (
+
+
Your notes{share !== null ? ` · ${share}% your own words` : ''}
}
+ {!r.same && r.then.trim() && Show the first draft
{r.then}
}
+
+ ))}
+
Highlighted words are new or reworded since the draft.
+
+
+ );
+}
diff --git a/src/components/study/NotesPane.jsx b/src/components/study/NotesPane.jsx
index c272a4d..67c644e 100644
--- a/src/components/study/NotesPane.jsx
+++ b/src/components/study/NotesPane.jsx
@@ -1,8 +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, withPaste, clearPastes } from '../../utils/ownWork.js';
+import { ownShare, ownWords, pastedWords, clearPastes } from '../../utils/ownWork.js';
import { templateFromChunk, templatePrompt } from '../../utils/studyTemplates.js';
const SECTIONS = [
@@ -17,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('');
@@ -38,9 +56,25 @@ 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 keepPaste = (field) => (e) => {
- const next = withPaste(chunk, field, e.clipboardData?.getData('text'));
- if (next) updateChunk(chunk.id, { aiPastes: next });
+ 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 (
@@ -72,15 +106,31 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
{isOpen && (
-
)}
@@ -106,6 +156,8 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
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}`} />
+
+
{saveTemplate && (
{!tplOpen ? (
diff --git a/src/components/study/PastePrompt.jsx b/src/components/study/PastePrompt.jsx
new file mode 100644
index 0000000..db085b6
--- /dev/null
+++ b/src/components/study/PastePrompt.jsx
@@ -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 ? 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 (
+
+
You pasted {words} words into {LABELS[field] ?? 'this note'}. Is this from AI?
+
Pasted text isn’t counted as your own work. Choose “I wrote it” if it came from you.
+
+
+
+
+
+
Pastes under {PASTE_MIN_WORDS} words aren’t asked about.
+
+ );
+}
diff --git a/src/components/study/ResourcePane.jsx b/src/components/study/ResourcePane.jsx
index 6b17906..77a5766 100644
--- a/src/components/study/ResourcePane.jsx
+++ b/src/components/study/ResourcePane.jsx
@@ -1,12 +1,13 @@
// 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 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' },
@@ -20,6 +21,17 @@ export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex
const iq = useIqAvailable();
const tabs = RESOURCE_TABS.filter((t) => !t.needsIq || iq);
if (tab === 'aids' && !iq) tab = 'commentary';
+ // 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 };
return (
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).
+
+ Who came
+ 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" />
+
+
+ What the group said
+