Invite-only sign-up, present mode, series, handouts, templates, dictation, progress, sharing
Build and Push Docker Image / build-and-push (push) Successful in 6m47s

- Admin: sign-up Open / Invite only / Closed with single-use invite links
- Present mode for scripts (auto-scroll) and outlines (step through, pace vs planned minutes)
- Series: group studies on the dashboard, filter by series, calendar file for a study's sessions
- Lesson handout: printable page per session (passage, outline, questions, writing lines)
- Save a chunk's notes as your own study template (synced)
- Voice dictation for notes and scripts (browser speech recognition)
- Reading progress: map of the 66 books and a calendar of days read, synced and merged across devices
- Export highlights and bookmarks as Markdown or Word, grouped by named colour
- Verse of the day push to the ntfy topic (needs IQBIBLE_API_KEY)
- Sharing: co-teacher editing via single-use invite links; read-only links can expire and take a passcode

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-06 10:26:43 -04:00
1 parent 88bc59bac0
commit 54cb1c217d
73 files changed
+2739 -134

No files matched your search

+87
View File
@@ -0,0 +1,87 @@
// Admin → Sign-ups: who may create an account, and invite codes for when it is invite-only.
import { useEffect, useState } from 'react';
import { adminCreateInvite, adminDeleteInvite, adminGetSignup, adminSetSignupMode } from '../syncService.js';
const MODES = [
['open', 'Open', 'Anyone who can reach this site can create an account.'],
['invite', 'Invite only', 'A new account needs an invite code from you.'],
['closed', 'Closed', 'Nobody new can sign up.'],
];
const btn = 'rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50';
export default function AdminSignup() {
const [state, setState] = useState({ mode: 'open', invites: [], loading: true, error: '' });
const [note, setNote] = useState('');
const [days, setDays] = useState(7);
const [copied, setCopied] = useState('');
const load = async () => {
const r = await adminGetSignup();
setState(r.ok ? { ...r.data, loading: false, error: '' } : { mode: 'open', invites: [], loading: false, error: r.error ?? 'Could not load sign-up settings.' });
};
useEffect(() => { load(); }, []);
const setMode = async (mode) => {
const r = await adminSetSignupMode(mode);
if (r.ok) setState((s) => ({ ...s, mode, error: '' })); else setState((s) => ({ ...s, error: r.error ?? 'Could not change that.' }));
};
const create = async () => {
const r = await adminCreateInvite({ note, days });
if (!r.ok) { setState((s) => ({ ...s, error: r.error ?? 'Could not make an invite.' })); return; }
setNote('');
load();
};
const link = (code) => `${window.location.origin}/?invite=${code}`;
const copy = async (code) => {
try { await navigator.clipboard.writeText(link(code)); setCopied(code); window.setTimeout(() => setCopied(''), 1800); } catch { /* clipboard unavailable */ }
};
const status = (inv) => (inv.usedBy ? 'Used' : inv.expiresAt < Date.now() ? 'Expired' : `Until ${new Date(inv.expiresAt).toLocaleDateString()}`);
if (state.loading) return <p className="text-sm text-[var(--r-sub)]">Loading…</p>;
return (
<div className="space-y-5">
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Who can sign up">
<h2 className="text-base font-medium">Who can sign up</h2>
<div role="radiogroup" aria-label="Sign-up setting" className="mt-3 space-y-2">
{MODES.map(([id, label, hint]) => (
<label key={id} className="flex cursor-pointer items-start gap-3 text-sm">
<input type="radio" name="signup-mode" checked={state.mode === id} onChange={() => setMode(id)} className="mt-1 accent-[var(--r-accent)]" />
<span><span className="font-medium">{label}</span><span className="block text-xs text-[var(--r-sub)]">{hint}</span></span>
</label>
))}
</div>
{state.error && <p role="alert" className="mt-3 text-sm text-[var(--r-danger,#b42318)]">{state.error}</p>}
</section>
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Invite codes">
<h2 className="text-base font-medium">Invite codes</h2>
<p className="mt-1 text-xs text-[var(--r-sub)]">Each code works once. Send the link, and it opens the sign-up page with the code filled in.{state.mode !== 'invite' ? ' (Codes only matter while sign-up is “Invite only”.)' : ''}</p>
<div className="mt-3 flex flex-wrap items-end gap-2">
<label className="text-xs text-[var(--r-sub)]">Who is it for (optional)
<input type="text" value={note} onChange={(e) => setNote(e.target.value)} maxLength={80} className="mt-1 block w-52 rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)]" />
</label>
<label className="text-xs text-[var(--r-sub)]">Good for
<select value={days} onChange={(e) => setDays(Number(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)]">
{[1, 7, 30].map((d) => <option key={d} value={d}>{d} day{d === 1 ? '' : 's'}</option>)}
</select>
</label>
<button type="button" onClick={create} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90">Make an invite</button>
</div>
{state.invites.length > 0 && (
<ul className="mt-4 divide-y divide-[var(--r-line)] text-sm">
{state.invites.map((inv) => (
<li key={inv.code} className="flex flex-wrap items-center justify-between gap-2 py-2">
<span><span className="font-mono tracking-wider">{inv.code}</span>{inv.note ? <span className="ml-2 text-[var(--r-sub)]">{inv.note}</span> : null}</span>
<span className="flex items-center gap-2">
<span className="text-xs text-[var(--r-sub)]">{status(inv)}</span>
{!inv.usedBy && inv.expiresAt >= Date.now() && <button type="button" onClick={() => copy(inv.code)} className={btn}>{copied === inv.code ? 'Copied' : 'Copy link'}</button>}
<button type="button" onClick={async () => { await adminDeleteInvite(inv.code); load(); }} className={`${btn} text-[var(--r-danger,#b42318)]`}>Delete</button>
</span>
</li>
))}
</ul>
)}
</section>
</div>
);
}
+85
View File
@@ -0,0 +1,85 @@
// Export → 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';
import { createEditInvite, getCollaborators, removeCollaborator } 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';
export default function CoTeachers({ projectId, sharedBy, onLeave }) {
const [state, setState] = useState({ status: 'loading', collaborators: [], pendingInvites: 0 });
const [invite, setInvite] = useState(null); // { url, expiresAt }
const [busy, setBusy] = useState(false);
const [copied, setCopied] = useState(false);
const [error, setError] = useState('');
const load = useCallback(async () => {
if (!projectId || sharedBy !== undefined) return;
const r = await getCollaborators(projectId);
setState(r.ok ? { status: 'ready', ...r.data } : { status: 'unavailable', collaborators: [], pendingInvites: 0 });
}, [projectId, sharedBy]);
useEffect(() => { setInvite(null); load(); }, [load]);
// Someone else's study that you were invited to edit
if (sharedBy !== undefined) {
return (
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Co-teachers">
<h2 className="text-sm font-medium">Shared with you</h2>
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">{sharedBy || 'Its owner'} invited you to edit this study. Your changes sync to them, and theirs to you.</p>
<button type="button" onClick={onLeave} className={btn}>Leave this study</button>
</section>
);
}
const makeInvite = async () => {
setBusy(true); setError('');
const r = await createEditInvite(projectId);
setBusy(false);
if (!r.ok) { setError(r.error ?? 'Could not make the link.'); return; }
setInvite({ url: `${window.location.origin}${window.location.pathname}?join=${r.data.token}`, expiresAt: r.data.expiresAt });
load();
};
const copy = async () => {
try { await navigator.clipboard.writeText(invite.url); setCopied(true); window.setTimeout(() => setCopied(false), 1800); } catch { setError('Couldn’t copy. Select the link and copy it.'); }
};
const remove = async (userId, email) => {
if (!window.confirm(`Remove ${email}? They will no longer be able to edit this study.`)) return;
const r = await removeCollaborator(projectId, userId);
if (r.ok) load(); else setError(r.error ?? 'Could not remove them.');
};
return (
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Co-teachers">
<h2 className="text-sm font-medium">Co-teachers</h2>
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">Let someone else edit this study with you. They need an account here. Each invite link works once and lasts 7 days.</p>
{state.status === 'unavailable' && <p className="text-xs text-[var(--r-sub)]">Invites need your account and a connection, and the study has to have synced once. Try again in a moment.</p>}
{state.status === 'ready' && (
<>
{state.collaborators.length > 0 && (
<ul className="mb-3 divide-y divide-[var(--r-line)] text-sm">
{state.collaborators.map((c) => (
<li key={c.userId} className="flex items-center justify-between gap-3 py-2">
<span>{c.email}</span>
<button type="button" onClick={() => remove(c.userId, c.email)} className="text-xs text-[var(--r-danger,#b42318)] hover:underline">Remove</button>
</li>
))}
</ul>
)}
{invite ? (
<div className="space-y-2">
<div className="flex items-center gap-2">
<input readOnly aria-label="Invite link" value={invite.url} onFocus={(e) => e.target.select()} className="min-w-0 flex-1 truncate rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-xs text-[var(--r-sub)]" />
<button type="button" onClick={copy} className="shrink-0 rounded-lg bg-[var(--r-accent)] px-4 py-2 text-xs font-medium text-[var(--r-on)]">{copied ? 'Copied!' : 'Copy'}</button>
</div>
<p className="text-[11px] text-[var(--r-sub)]">Send it to one person. It stops working once they use it, or on {new Date(invite.expiresAt).toLocaleDateString()}.</p>
</div>
) : (
<button type="button" onClick={makeInvite} disabled={busy} className={btn}>{busy ? 'Making a link…' : 'Invite a co-teacher'}</button>
)}
{state.pendingInvites > 0 && !invite && <p className="mt-2 text-[11px] text-[var(--r-sub)]">{state.pendingInvites} invite{state.pendingInvites === 1 ? '' : 's'} waiting to be used.</p>}
</>
)}
{error && <p role="alert" className="mt-2 text-xs text-[var(--r-danger,#b42318)]">{error}</p>}
</section>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect, beforeEach } from 'vitest';
vi.mock('../syncService.js', () => ({ getCollaborators: vi.fn(), createEditInvite: vi.fn(), removeCollaborator: vi.fn() }));
import CoTeachers from './CoTeachers.jsx';
import { createEditInvite, getCollaborators, removeCollaborator } from '../syncService.js';
beforeEach(() => {
vi.clearAllMocks();
getCollaborators.mockResolvedValue({ ok: true, data: { collaborators: [{ userId: 'u2', email: 'sam@x.co' }], pendingInvites: 0 } });
createEditInvite.mockResolvedValue({ ok: true, data: { token: 'abc123', expiresAt: Date.now() + 7 * 86_400_000 } });
removeCollaborator.mockResolvedValue({ ok: true });
vi.spyOn(window, 'confirm').mockReturnValue(true);
});
test('the owner sees the co-teachers and can make a single-use invite link', async () => {
const user = userEvent.setup();
render(<CoTeachers projectId="p1" />);
expect(await screen.findByText('sam@x.co')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /invite a co-teacher/i }));
const link = await screen.findByLabelText('Invite link');
expect(link.value).toContain('?join=abc123');
expect(createEditInvite).toHaveBeenCalledWith('p1');
});
test('the owner can remove a co-teacher after confirming', async () => {
const user = userEvent.setup();
render(<CoTeachers projectId="p1" />);
await user.click(await screen.findByRole('button', { name: 'Remove' }));
expect(removeCollaborator).toHaveBeenCalledWith('p1', 'u2');
});
test('someone invited to a study sees who shared it and can leave', async () => {
const onLeave = vi.fn();
const user = userEvent.setup();
render(<CoTeachers projectId="p1" sharedBy="owner@x.co" onLeave={onLeave} />);
expect(screen.getByText(/owner@x.co invited you to edit/)).toBeInTheDocument();
expect(getCollaborators).not.toHaveBeenCalled(); // only owners can list co-teachers
await user.click(screen.getByRole('button', { name: /leave this study/i }));
expect(onLeave).toHaveBeenCalled();
});
test('when the server can\'t be reached, it says so instead of offering a link', async () => {
getCollaborators.mockResolvedValue({ ok: false, status: 404, error: 'Project not found.' });
render(<CoTeachers projectId="p1" />);
await waitFor(() => expect(screen.getByText(/Invites need your account/)).toBeInTheDocument());
expect(screen.queryByRole('button', { name: /invite a co-teacher/i })).toBeNull();
});
+68
View File
@@ -0,0 +1,68 @@
// A microphone button for a text box: tap, speak, and what you say is added to the text. It uses the browser's
// own speech recognition (Safari and Chrome have it; Firefox does not, and then the button isn't shown). The
// browser may send the audio to its maker's speech service to be turned into words, which is why it asks to use
// the microphone, and why this is a button you press rather than something that listens in the background.
import { useEffect, useRef, useState } from 'react';
export const speechRecognitionClass = () => (typeof window === 'undefined' ? null : window.SpeechRecognition ?? window.webkitSpeechRecognition ?? null);
// Adds spoken words to existing text: a space (or nothing after a newline) between, and a first letter capitalised after a full stop.
export function appendSpoken(existing, spoken) {
const said = String(spoken ?? '').trim();
if (!said) return existing ?? '';
const base = existing ?? '';
if (!base.trim()) return `${said.charAt(0).toUpperCase()}${said.slice(1)}`;
const gap = /[\s]$/.test(base) ? '' : ' ';
const afterStop = /[.!?]["')\]]*\s*$/.test(base);
return `${base}${gap}${afterStop ? `${said.charAt(0).toUpperCase()}${said.slice(1)}` : said}`;
}
const MESSAGES = {
'not-allowed': 'The microphone is blocked. Allow it for this site in your browser’s settings.',
'service-not-allowed': 'The microphone is blocked. Allow it for this site in your browser’s settings.',
'audio-capture': 'No microphone was found.',
network: 'Speech recognition needs a connection.',
};
export default function DictateButton({ onText, label = 'Dictate', className = '' }) {
const Recognition = speechRecognitionClass();
const [listening, setListening] = useState(false);
const [error, setError] = useState('');
const recRef = useRef(null);
const onTextRef = useRef(onText);
onTextRef.current = onText;
useEffect(() => () => { try { recRef.current?.abort(); } catch { /* already stopped */ } }, []);
if (!Recognition) return null;
const start = () => {
setError('');
const rec = new Recognition();
rec.lang = navigator.language || 'en-US';
rec.continuous = true;
rec.interimResults = false;
rec.onresult = (event) => {
for (let i = event.resultIndex; i < event.results.length; i += 1) {
if (event.results[i].isFinal) onTextRef.current(event.results[i][0].transcript);
}
};
rec.onerror = (event) => { if (event.error !== 'no-speech' && event.error !== 'aborted') setError(MESSAGES[event.error] ?? 'Dictation stopped. Try again.'); };
rec.onend = () => { setListening(false); recRef.current = null; };
recRef.current = rec;
try { rec.start(); setListening(true); } catch { setListening(false); }
};
const stop = () => { try { recRef.current?.stop(); } catch { /* already stopped */ } };
return (
<span className={`inline-flex items-center gap-2 ${className}`}>
<button type="button" onClick={listening ? stop : start} aria-pressed={listening} aria-label={listening ? 'Stop dictating' : label}
title={listening ? 'Stop dictating' : 'Speak your notes'}
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs ${listening ? 'border-[var(--r-danger,#b42318)] text-[var(--r-danger,#b42318)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
<svg viewBox="0 0 20 20" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true"><rect x="7" y="2.5" width="6" height="10" rx="3" /><path d="M4.5 9.5a5.5 5.5 0 0 0 11 0M10 15v2.5" strokeLinecap="round" /></svg>
{listening ? 'Listening… tap to stop' : label}
{listening && <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-[var(--r-danger,#b42318)] motion-reduce:animate-none" aria-hidden="true" />}
</button>
{error && <span role="alert" className="text-[11px] text-[var(--r-danger,#b42318)]">{error}</span>}
</span>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { render, screen, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect, afterEach } from 'vitest';
import DictateButton, { appendSpoken } from './DictateButton.jsx';
afterEach(() => { delete window.SpeechRecognition; delete window.webkitSpeechRecognition; });
// A stand-in for the browser's recogniser that we can make "hear" things
function fakeRecognition() {
const instances = [];
class Fake {
constructor() { instances.push(this); this.started = false; }
start() { this.started = true; }
stop() { this.started = false; this.onend?.(); }
abort() { this.started = false; }
}
window.webkitSpeechRecognition = Fake;
return instances;
}
const hear = (rec, text, isFinal = true) => rec.onresult({ resultIndex: 0, results: [Object.assign([{ transcript: text }], { isFinal })] });
test('spoken words are added to what is already written', () => {
expect(appendSpoken('', ' grace is a gift ')).toBe('Grace is a gift');
expect(appendSpoken('Paul writes', 'to Titus')).toBe('Paul writes to Titus');
expect(appendSpoken('He saved us.', 'not by works')).toBe('He saved us. Not by works');
expect(appendSpoken('Line one\n', 'line two')).toBe('Line one\nline two');
expect(appendSpoken('Keep this', ' ')).toBe('Keep this');
});
test('with no speech recognition in the browser, the button is not shown', () => {
const { container } = render(<DictateButton onText={() => {}} />);
expect(container).toBeEmptyDOMElement();
});
test('tap to listen, final phrases are passed on, tap again to stop', async () => {
const instances = fakeRecognition();
const onText = vi.fn();
const user = userEvent.setup();
render(<DictateButton onText={onText} label="Dictate script" />);
await user.click(screen.getByRole('button', { name: 'Dictate script' }));
expect(instances[0].started).toBe(true);
expect(screen.getByRole('button', { name: 'Stop dictating' })).toHaveAttribute('aria-pressed', 'true');
act(() => hear(instances[0], 'the Lord is my shepherd', false)); // still being worked out: ignored
act(() => hear(instances[0], 'the Lord is my shepherd'));
expect(onText).toHaveBeenCalledTimes(1);
expect(onText).toHaveBeenCalledWith('the Lord is my shepherd');
await user.click(screen.getByRole('button', { name: 'Stop dictating' }));
expect(screen.getByRole('button', { name: 'Dictate script' })).toHaveAttribute('aria-pressed', 'false');
});
test('a blocked microphone is explained', async () => {
const instances = fakeRecognition();
const user = userEvent.setup();
render(<DictateButton onText={() => {}} />);
await user.click(screen.getByRole('button', { name: 'Dictate' }));
act(() => instances[0].onerror({ error: 'not-allowed' }));
expect(await screen.findByRole('alert')).toHaveTextContent(/microphone is blocked/i);
act(() => instances[0].onerror({ error: 'no-speech' })); // silence is not an error worth showing
});
+29
View File
@@ -0,0 +1,29 @@
// Export → 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';
export default function HandoutExport({ project }) {
const sessions = sessionsFor(project);
const [which, setWhich] = useState('all');
const [lines, setLines] = useState(true);
const [blocked, setBlocked] = useState(false);
if (sessions.length === 0) return null;
const go = () => setBlocked(!printHandout(project, which === 'all' ? null : [which], { answerLines: lines }));
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>
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">A page per session with the passage, the outline, discussion questions and room to write. Use “Save as PDF” in the print dialog. Write the questions in the Study stage under “More”; without them, your Application notes are used.</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} (one page each)</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={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>
{blocked && <p role="alert" className="mt-2 text-xs text-[var(--r-danger,#b42318)]">Allow pop-ups for this site to print the handout.</p>}
</section>
);
}
+45
View File
@@ -0,0 +1,45 @@
// The rules for a read-only link: when it stops working, and an optional passcode. Shown under the link.
import { useState } from 'react';
const OPTIONS = [['', 'Never'], ['1', 'In 1 day'], ['7', 'In 7 days'], ['30', 'In 30 days']];
const field = 'rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
export const expiryText = (expiresAt, now = Date.now()) => {
if (!expiresAt) return 'Never expires';
if (expiresAt <= now) return 'Expired';
const days = Math.ceil((expiresAt - now) / 86_400_000);
return `Expires ${new Date(expiresAt).toLocaleDateString()} (${days} day${days === 1 ? '' : 's'} left)`;
};
export default function LinkRules({ expiresAt, hasPasscode, busy, onSave }) {
const [days, setDays] = useState('keep'); // 'keep' | '' | '1' | '7' | '30'
const [passcode, setPasscode] = useState('');
const dirty = days !== 'keep' || passcode.trim() !== '';
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(''); }
};
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>
<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`}>
<option value="keep">{expiresAt ? 'Keep as is' : 'Never (as is)'}</option>
{OPTIONS.map(([v, label]) => <option key={v || 'never'} value={v}>{label}</option>)}
</select>
</label>
<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>
<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>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect } from 'vitest';
import LinkRules, { expiryText } from './LinkRules.jsx';
import SharedPasscodeForm from './SharedPasscodeForm.jsx';
test('says when a link expires and whether it has a passcode', () => {
const now = Date.UTC(2026, 9, 6);
expect(expiryText(null, now)).toBe('Never expires');
expect(expiryText(now - 1, now)).toBe('Expired');
expect(expiryText(now + 3 * 86_400_000, now)).toMatch(/3 days left/);
render(<LinkRules expiresAt={null} hasPasscode busy={false} onSave={vi.fn()} />);
expect(screen.getByText(/Needs a passcode/)).toBeInTheDocument();
});
test('only the settings you change are sent', async () => {
const onSave = vi.fn(async () => true);
const user = userEvent.setup();
render(<LinkRules expiresAt={null} hasPasscode={false} busy={false} onSave={onSave} />);
const save = screen.getByRole('button', { name: 'Save settings' });
expect(save).toBeDisabled();
await user.selectOptions(screen.getByLabelText('Stop working'), '7');
await user.click(save);
expect(onSave).toHaveBeenLastCalledWith({ expiresInDays: 7 }); // no passcode key: the passcode is left alone
await user.type(screen.getByLabelText(/Passcode/), ' moses ');
await user.click(screen.getByRole('button', { name: 'Save settings' }));
expect(onSave).toHaveBeenLastCalledWith({ passcode: 'moses' });
});
test('a passcode can be removed, and "Never" clears the expiry', async () => {
const onSave = vi.fn(async () => true);
const user = userEvent.setup();
render(<LinkRules expiresAt={Date.now() + 86_400_000} hasPasscode busy={false} onSave={onSave} />);
await user.click(screen.getByRole('button', { name: 'Remove passcode' }));
expect(onSave).toHaveBeenLastCalledWith({ passcode: null });
await user.selectOptions(screen.getByLabelText('Stop working'), 'Never');
await user.click(screen.getByRole('button', { name: 'Save settings' }));
expect(onSave).toHaveBeenLastCalledWith({ expiresInDays: null });
});
test('the viewer\'s passcode form submits what was typed and shows a wrong-passcode message', async () => {
const onSubmit = vi.fn();
const user = userEvent.setup();
render(<SharedPasscodeForm error="That passcode is not right." onSubmit={onSubmit} />);
expect(screen.getByRole('alert')).toHaveTextContent(/not right/);
expect(screen.getByRole('button', { name: 'Open' })).toBeDisabled();
await user.type(screen.getByLabelText('Passcode'), 'moses');
await user.click(screen.getByRole('button', { name: 'Open' }));
expect(onSubmit).toHaveBeenCalledWith('moses');
});
+137
View File
@@ -0,0 +1,137 @@
// 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.
import { useCallback, useEffect, useRef, useState } from 'react';
import { formatClock, outlineSteps, paceText, pacing } from '../utils/present.js';
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';
export default function PresentMode({ title, subtitle = '', mode, script = '', tree = [], onClose }) {
const [dark, setDark] = useState(true);
const [size, setSize] = useState(() => { try { return Math.max(0, SIZES.indexOf(Number(localStorage.getItem('present-size')) || 34)); } catch { return 3; } });
const [elapsed, setElapsed] = useState(0);
const [running, setRunning] = useState(false);
const [scrolling, setScrolling] = useState(false);
const [speed, setSpeed] = useState(30); // pixels per second
const [index, setIndex] = useState(0);
const scroller = useRef(null);
const steps = mode === 'outline' ? outlineSteps(tree) : [];
const step = steps[index];
const pace = mode === 'outline' ? pacing(steps, index, elapsed) : null;
// the clock
useEffect(() => {
if (!running) return undefined;
const t = window.setInterval(() => setElapsed((e) => e + 1), 1000);
return () => window.clearInterval(t);
}, [running]);
// the teleprompter
useEffect(() => {
if (!scrolling || mode !== 'script') return undefined;
let last = performance.now();
let frame;
let carry = 0;
const tick = (now) => {
const el = scroller.current;
carry += ((now - last) / 1000) * speed;
last = now;
if (el && carry >= 1) { el.scrollTop += Math.floor(carry); carry -= Math.floor(carry); }
if (el && el.scrollTop + el.clientHeight >= el.scrollHeight - 2) { setScrolling(false); return; }
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [scrolling, speed, mode]);
// keep the screen on
useEffect(() => {
let lock;
let gone = false;
navigator.wakeLock?.request?.('screen').then((l) => { if (gone) l.release?.(); else lock = l; }).catch(() => {});
return () => { gone = true; lock?.release?.().catch?.(() => {}); };
}, []);
const bigger = (d) => setSize((s) => {
const next = Math.min(SIZES.length - 1, Math.max(0, s + d));
try { localStorage.setItem('present-size', String(SIZES[next])); } catch { /* storage unavailable */ }
return next;
});
const go = useCallback((d) => setIndex((i) => Math.min(steps.length - 1, Math.max(0, i + d))), [steps.length]);
useEffect(() => {
const onKey = (e) => {
if (e.key === 'Escape') { onClose(); return; }
if (e.target?.tagName === 'INPUT') return;
if (mode === 'outline') {
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 === '+' || e.key === '=') bigger(1);
if (e.key === '-') bigger(-1);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [mode, go, onClose]);
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;
return (
<div role="dialog" aria-modal="true" aria-label={`Presenting ${title}`} className={`fixed inset-0 z-[80] flex flex-col ${colors}`}>
<div className="flex shrink-0 flex-wrap items-center gap-x-4 gap-y-2 border-b border-current/15 px-4 py-2.5">
<div className="min-w-0 flex-1"><p className="truncate text-sm font-medium">{title}</p>{subtitle && <p className="truncate text-xs opacity-60">{subtitle}</p>}</div>
<div className="flex items-center gap-2" role="group" aria-label="Timer">
<span className="min-w-[4.5rem] text-center font-mono text-xl tabular-nums" aria-live="off">{formatClock(elapsed)}</span>
<button type="button" onClick={() => setRunning((r) => !r)} className={btn}>{running ? 'Pause' : elapsed ? 'Resume' : 'Start'}</button>
<button type="button" onClick={() => { setElapsed(0); setRunning(false); }} disabled={!elapsed} className={btn}>Reset</button>
</div>
{pace && <span className={`text-sm ${pace.diffSeconds < -20 ? 'text-amber-400' : 'opacity-80'}`} role="status">{paceText(pace)} · plan {formatClock(pace.totalSeconds)}</span>}
<div className="flex items-center gap-1.5" role="group" aria-label="Text size">
<button type="button" onClick={() => bigger(-1)} disabled={size === 0} aria-label="Smaller text" className={btn}>A−</button>
<button type="button" onClick={() => bigger(1)} disabled={size === SIZES.length - 1} aria-label="Larger text" className={btn}>A+</button>
<button type="button" onClick={() => setDark((d) => !d)} aria-pressed={dark} className={btn}>{dark ? 'Light' : 'Dark'}</button>
</div>
{mode === 'script' && (
<div className="flex items-center gap-2" role="group" aria-label="Auto-scroll">
<button type="button" onClick={() => setScrolling((s) => !s)} aria-pressed={scrolling} className={btn}>{scrolling ? 'Stop scrolling' : 'Auto-scroll'}</button>
<label className="flex items-center gap-1.5 text-xs opacity-80">Speed
<input type="range" min="8" max="120" value={speed} onChange={(e) => setSpeed(Number(e.target.value))} aria-label="Scroll speed" />
</label>
</div>
)}
<button type="button" onClick={onClose} className={`${btn} font-medium`}>Close</button>
</div>
{mode === 'script' ? (
<div ref={scroller} className="min-h-0 flex-1 overflow-y-auto px-[6vw] py-[8vh]" 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: '50vh' }} 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">
<div className="min-h-0 flex-1 overflow-y-auto px-[6vw] py-[6vh]">
{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>
) : <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>
<button type="button" onClick={() => go(1)} disabled={index >= steps.length - 1} className={btn}>Next →</button>
</div>
</div>
)}
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { render, screen, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect, afterEach } from 'vitest';
import PresentMode from './PresentMode.jsx';
afterEach(() => vi.useRealTimers());
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' })];
test('the outline is stepped through a point at a time, with the keyboard or the buttons', async () => {
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.getByText(/Point 1 of 2/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /back/i })).toBeDisabled();
await user.keyboard('{ArrowRight}');
expect(screen.getByText('The gospel')).toBeInTheDocument();
expect(screen.getByText('Rom 1:16')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();
await user.click(screen.getByRole('button', { name: /back/i }));
expect(screen.getByText('Welcome')).toBeInTheDocument();
});
test('the timer runs, pauses and resets, and tells you how you are doing against the plan', async () => {
vi.useFakeTimers();
render(<PresentMode mode="outline" title="Romans" tree={tree} onClose={() => {}} />);
const group = screen.getByRole('group', { name: 'Timer' });
const click = (name) => act(() => { screen.getByRole('button', { name }).click(); });
click('Start');
act(() => { vi.advanceTimersByTime(65_000); });
expect(group).toHaveTextContent('1:05');
expect(screen.getByRole('status')).toHaveTextContent(/On pace/); // a minute in, on a 2-minute point
click('Pause');
act(() => { vi.advanceTimersByTime(10_000); });
expect(group).toHaveTextContent('1:05'); // paused
click('Reset');
expect(group).toHaveTextContent('0:00');
});
test('the text size can be changed and the choice is remembered', async () => {
const user = userEvent.setup();
localStorage.clear();
render(<PresentMode mode="script" title="Romans 8" script="In the beginning" onClose={() => {}} />);
const text = screen.getByText('In the beginning');
expect(text.style.fontSize).toBe('34px');
await user.click(screen.getByRole('button', { name: 'Larger text' }));
expect(text.style.fontSize).toBe('40px');
expect(localStorage.getItem('present-size')).toBe('40');
});
test('Escape and Close both leave, and an empty script says what to do', async () => {
const onClose = vi.fn();
const user = userEvent.setup();
render(<PresentMode mode="script" title="T" script=" " onClose={onClose} />);
expect(screen.getByText(/no script to present yet/i)).toBeInTheDocument();
await user.keyboard('{Escape}');
await user.click(screen.getByRole('button', { name: 'Close' }));
expect(onClose).toHaveBeenCalledTimes(2);
});
+60
View File
@@ -0,0 +1,60 @@
// "Your reading": how much of the Bible you have read (every chapter you spent time on in the Reader, plus the
// ones you finished in reading plans), a tile for each book that fills as you read it, and a calendar of the
// days you read in the last six months.
import { useMemo } from 'react';
import { getPlanSchedule } from '../utils/readingPlans.js';
import { heatmapWeeks, planChapterKeys, progressSummary } from '../utils/readingHistory.js';
const pct = (n) => `${Math.round(n * 100)}%`;
export default function ReadingProgress({ history, planProgress = [], activity = [], onOpenBook }) {
const summary = useMemo(() => progressSummary(history, planChapterKeys(planProgress, getPlanSchedule)), [history, planProgress]);
const days = useMemo(() => new Set([...(history.days ?? []), ...activity]), [history.days, activity]);
const weeks = useMemo(() => heatmapWeeks(days), [days]);
const daysInView = weeks.flat().filter((d) => d.on).length;
const group = (list) => (
<div className="grid grid-cols-[repeat(auto-fill,minmax(5.5rem,1fr))] gap-1.5">
{list.map((b) => (
<button key={b.abbrev} type="button" onClick={() => onOpenBook?.(b.abbrev)} title={`${b.name}: ${b.read} of ${b.total} chapters read`}
aria-label={`${b.name}, ${b.read} of ${b.total} chapters read`}
className="relative overflow-hidden rounded-lg border border-[var(--r-line)] px-2 py-1.5 text-left text-xs hover:border-[var(--r-accent)]">
<span aria-hidden="true" className="absolute inset-y-0 left-0 bg-[var(--r-accent)] opacity-25" style={{ width: pct(b.pct) }} />
<span className="relative block truncate font-medium">{b.name}</span>
<span className="relative block text-[10px] text-[var(--r-sub)]">{b.read}/{b.total}</span>
</button>
))}
</div>
);
return (
<section aria-label="Your reading" className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h2 className="text-sm font-medium">Your reading</h2>
<p className="text-xs text-[var(--r-sub)]">
<span className="text-base font-medium text-[var(--r-text)]">{pct(summary.pct)}</span> of the Bible · {summary.read} of {summary.total} chapters
</p>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-[var(--r-line)]" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(summary.pct * 100)} aria-label="Bible read">
<div className="h-full bg-[var(--r-accent)]" style={{ width: pct(summary.pct) }} />
</div>
<p className="mt-1.5 text-[11px] text-[var(--r-sub)]">Old Testament {summary.ot.read}/{summary.ot.total} · New Testament {summary.nt.read}/{summary.nt.total}. A chapter counts once you’ve had it open for a bit in the Reader, or finished it in a plan.</p>
<h3 className="mb-1.5 mt-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Old Testament</h3>
{group(summary.books.filter((b) => b.testament === 'ot'))}
<h3 className="mb-1.5 mt-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">New Testament</h3>
{group(summary.books.filter((b) => b.testament === 'nt'))}
<h3 className="mb-1.5 mt-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Days you read · {daysInView} in the last 6 months</h3>
<div className="flex gap-[3px] overflow-x-auto pb-1" role="img" aria-label={`${daysInView} days read in the last six months`}>
{weeks.map((week) => (
<div key={week[0].date} className="flex flex-col gap-[3px]">
{week.map((d) => (
<span key={d.date} title={d.future ? '' : `${d.date}${d.on ? ': read' : ''}`}
className={`h-3 w-3 rounded-[3px] ${d.future ? 'opacity-0' : d.on ? 'bg-[var(--r-accent)]' : 'bg-[var(--r-line)]'}`} />
))}
</div>
))}
</div>
</section>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect } from 'vitest';
import ReadingProgress from './ReadingProgress.jsx';
const history = { chapters: { 'GEN.1': { n: 1, last: '2026-10-01' }, 'GEN.2': { n: 2, last: '2026-10-02' }, 'JHN.3': { n: 1, last: '2026-10-03' } }, days: ['2026-10-01', '2026-10-02'] };
test('shows how much of the Bible is read, and each book\'s progress', () => {
render(<ReadingProgress history={history} planProgress={[]} activity={[]} />);
expect(screen.getByText(/3 of 1189 chapters/)).toBeInTheDocument();
expect(screen.getByRole('progressbar', { name: 'Bible read' })).toHaveAttribute('aria-valuenow', '0');
expect(screen.getByRole('button', { name: 'Genesis, 2 of 50 chapters read' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'John, 1 of 21 chapters read' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Revelation, 0 of 22 chapters read' })).toBeInTheDocument();
expect(screen.getAllByRole('button')).toHaveLength(66);
});
test('days from plans count on the calendar too, and a book opens in the Reader', async () => {
const onOpenBook = vi.fn();
render(<ReadingProgress history={history} planProgress={[]} activity={['2026-10-05']} onOpenBook={onOpenBook} />);
expect(screen.getByRole('img', { name: /3 days read in the last six months/ })).toBeInTheDocument();
await userEvent.setup().click(screen.getByRole('button', { name: /^Genesis/ }));
expect(onOpenBook).toHaveBeenCalledWith('GEN');
});
test('an empty history is fine', () => {
render(<ReadingProgress history={{ chapters: {}, days: [] }} />);
expect(screen.getByText(/0 of 1189 chapters/)).toBeInTheDocument();
expect(within(screen.getByRole('img')).queryAllByTitle(/: read/)).toHaveLength(0);
});
+64
View File
@@ -0,0 +1,64 @@
// Export → 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';
const field = 'rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
const isoDay = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
// The study's sessions in order: { title, description } for each chunk.
export function sessionsOf(project) {
const list = [];
(project?.chapters ?? []).forEach((chapter, chapterIndex) => {
for (const chunk of chapter.chunks ?? []) {
const ref = formatChunkReference(project, chapterIndex, chunk, '–');
const n = list.length + 1;
const label = `Session ${(chunk.episodeNumber ?? '').toString().trim() || n}`;
const name = (project.series ?? '').trim() || project.title;
list.push({ title: `${name} — ${label}: ${(chunk.episodeTitle ?? '').trim() || ref}`, description: `${project.title}\n${ref}` });
}
});
return list;
}
export default function SessionSchedule({ project }) {
const sessions = sessionsOf(project);
const [start, setStart] = useState(() => isoDay(new Date()));
const [every, setEvery] = useState('7');
const [time, setTime] = useState('');
const [minutes, setMinutes] = useState('60');
if (sessions.length === 0) return null;
const download = () => {
const [y, m, d] = start.split('-').map(Number);
const dates = sessionDates(new Date(y, m - 1, d), sessions.length, Number(every));
const events = sessions.map((s, i) => ({ ...s, date: dates[i], time: time || undefined, minutes: Number(minutes) || 60 }));
downloadIcs(buildIcs(events, { name: (project.series ?? '').trim() || project.title }), `${(project.title || 'study').replace(/[^\w]+/g, '-').toLowerCase()}-schedule.ics`);
};
return (
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Schedule">
<h2 className="text-sm font-medium">Schedule</h2>
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">Put the {sessions.length} session{sessions.length === 1 ? '' : 's'} on your calendar: one per chunk, in order, using each chunk’s session number and title.</p>
<div className="flex flex-wrap items-end gap-3">
<label className="text-xs text-[var(--r-sub)]">First session
<input type="date" value={start} onChange={(e) => setStart(e.target.value)} className={`${field} mt-1 block`} />
</label>
<label className="text-xs text-[var(--r-sub)]">Then every
<select value={every} onChange={(e) => setEvery(e.target.value)} className={`${field} mt-1 block`}>
<option value="1">day</option><option value="7">week</option><option value="14">2 weeks</option><option value="30">30 days</option>
</select>
</label>
<label className="text-xs text-[var(--r-sub)]">At (optional)
<input type="time" value={time} onChange={(e) => setTime(e.target.value)} className={`${field} mt-1 block`} />
</label>
{time && (
<label className="text-xs text-[var(--r-sub)]">Minutes
<input type="number" min="5" max="480" value={minutes} onChange={(e) => setMinutes(e.target.value)} className={`${field} mt-1 block w-20`} />
</label>
)}
<button type="button" onClick={download} disabled={!start} className="rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5 disabled:opacity-50">Download calendar file (.ics)</button>
</div>
</section>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect } from 'vitest';
vi.mock('../utils/icsFile.js', async (orig) => ({ ...(await orig()), downloadIcs: vi.fn() }));
import SessionSchedule, { sessionsOf } from './SessionSchedule.jsx';
import { downloadIcs } from '../utils/icsFile.js';
const chunk = (id, s, e, x = {}) => ({ id, startVerse: s, endVerse: e, ...x });
const project = {
title: 'Romans 8', series: 'Romans', chapters: [{ book: 'Romans', bookAbbrev: 'ROM', chapter: '8', verses: [], chunks: [
chunk('a', 1, 11, { episodeNumber: '4', episodeTitle: 'No condemnation' }), chunk('b', 12, 17),
] }],
};
test('each chunk is a session named for the series, its number and title (or passage)', () => {
const s = sessionsOf(project);
expect(s[0].title).toBe('Romans — Session 4: No condemnation');
expect(s[1].title).toMatch(/^Romans — Session 2: Romans 8:12/);
});
test('the calendar file has one event per session, spaced as asked', async () => {
const user = userEvent.setup();
render(<SessionSchedule project={project} />);
await user.clear(screen.getByLabelText('First session'));
await user.type(screen.getByLabelText('First session'), '2026-10-11');
await user.click(screen.getByRole('button', { name: /download calendar file/i }));
const [text, name] = downloadIcs.mock.calls[0];
expect(name).toBe('romans-8-schedule.ics');
expect(text.match(/BEGIN:VEVENT/g)).toHaveLength(2);
expect(text).toContain('DTSTART;VALUE=DATE:20261011');
expect(text).toContain('DTSTART;VALUE=DATE:20261018'); // a week later
});
test('a study with no chunks shows nothing', () => {
const { container } = render(<SessionSchedule project={{ title: 'x', chapters: [] }} />);
expect(container).toBeEmptyDOMElement();
});
+3 -1
View File
@@ -1,5 +1,6 @@
// Share an outline: a read-only link anyone can open (no account), or the plain text.
import { useState } from 'react';
import LinkRules from './LinkRules.jsx';
import Sheet from './reader/Sheet.jsx';
import { useToast } from './Toasts.jsx';
import { useOutlineShareLink } from '../hooks/useOutlineShareLink.js';
@@ -49,10 +50,11 @@ export default function ShareOutlineSheet({ open, onClose, projectId, outline })
<button type="button" onClick={shareLink} className={btn}>Share link…</button>
<button type="button" onClick={link.turnOff} disabled={link.busy} className="text-xs text-[var(--r-danger,#b42318)] hover:underline">{link.busy ? 'Turning off…' : 'Turn off link'}</button>
</div>
<LinkRules expiresAt={link.expiresAt} hasPasscode={link.hasPasscode} busy={link.busy} onSave={async (options) => { await link.create(options); return true; }} />
<p className="text-[11px] text-[var(--r-sub)]">People see the version that last synced to your account, and it updates as you edit. Turning the link off stops it working right away.</p>
</div>
) : (
<button type="button" onClick={link.create} disabled={link.busy} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50">{link.busy ? 'Creating…' : 'Create read-only link'}</button>
<button type="button" onClick={() => link.create()} disabled={link.busy} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50">{link.busy ? 'Creating…' : 'Create read-only link'}</button>
))}
{link.error && <p role="alert" className="mt-2 text-xs text-[var(--r-danger,#b42318)]">{link.error}</p>}
+17 -7
View File
@@ -1,8 +1,9 @@
// What someone sees when they open a read-only outline link: a calm page with the outline, a switcher
// when there is more than one, and Print / PDF. Nothing is editable.
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { getSharedOutline } from '../syncService.js';
import { themeVars } from './reader/readerThemes.js';
import SharedPasscodeForm from './SharedPasscodeForm.jsx';
import OutlineDocument from './OutlineDocument.jsx';
import { printOutline } from '../utils/outlineExport.js';
import { flatten } from '../utils/outline.js';
@@ -17,18 +18,27 @@ export default function SharedOutlineView({ token }) {
const [data, setData] = useState(null);
const [error, setError] = useState('');
const [scope, setScope] = useState('study');
const [needsPasscode, setNeedsPasscode] = useState(false);
const [passcodeError, setPasscodeError] = useState('');
const [checking, setChecking] = useState(false);
useEffect(() => {
let active = true;
getSharedOutline(token).then((result) => {
if (!active) return;
if (result.ok) { setData(result.data); setScope(result.data.outlines[0]?.scope ?? 'study'); } else setError(result.error ?? 'This outline link is invalid or has been turned off.');
const load = useCallback((passcode) => {
setChecking(true);
return getSharedOutline(token, passcode).then((result) => {
setChecking(false);
if (result.ok) { setData(result.data); setNeedsPasscode(false); setScope(result.data.outlines[0]?.scope ?? 'study'); return; }
if (result.data?.needsPasscode) { setNeedsPasscode(true); setPasscodeError(passcode ? result.error : ''); return; }
setNeedsPasscode(false);
setError(result.error ?? 'This outline link is invalid or has been turned off.');
});
return () => { active = false; };
}, [token]);
useEffect(() => { load(); }, [load]);
useEffect(() => { if (data?.title) document.title = `${data.title} — Outline`; }, [data]);
if (needsPasscode) return <Frame><SharedPasscodeForm error={passcodeError} busy={checking} onSubmit={load} /></Frame>;
if (error) {
return (
<Frame>
+20
View File
@@ -0,0 +1,20 @@
// What someone sees when a shared link needs a passcode.
import { useState } from 'react';
export default function SharedPasscodeForm({ error, busy, onSubmit, dark = false }) {
const [value, setValue] = useState('');
return (
<div className={`flex min-h-dvh items-center justify-center px-4 ${dark ? 'bg-slate-900' : 'bg-[var(--r-bg)] text-[var(--r-text)]'}`}>
<form onSubmit={(e) => { e.preventDefault(); if (value.trim()) onSubmit(value.trim()); }}
className="w-full max-w-sm rounded-3xl border border-black/10 bg-white p-8 text-center text-slate-900 shadow-lg">
<p className="text-xs uppercase tracking-[0.24em] text-slate-400">Bible Study Project</p>
<h1 className="mt-4 text-lg font-medium">This link has a passcode</h1>
<p className="mt-1 text-sm text-slate-500">Enter the passcode the person who shared it gave you.</p>
<input type="password" autoFocus autoComplete="off" aria-label="Passcode" value={value} onChange={(e) => setValue(e.target.value)}
className="mt-5 block w-full rounded-xl border border-slate-300 px-3 py-2.5 text-center text-sm" />
{error && <p role="alert" className="mt-3 text-sm text-rose-600">{error}</p>}
<button type="submit" disabled={busy || !value.trim()} className="mt-5 w-full rounded-full bg-slate-900 px-4 py-2.5 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50">{busy ? 'Checking…' : 'Open'}</button>
</form>
</div>
);
}
+18
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from 'react';
import Sheet from './Sheet.jsx';
import { CloseIcon } from './icons.jsx';
import { buildMarksDocxBlob, buildMarksModel, downloadMarksFile, marksToMarkdown } from '../../utils/marksExport.js';
import { COLOR_NAMES, COLOR_SUGGESTIONS, HIGHLIGHT_COLORS, colorTitle } from '../../utils/highlightColors.js';
const tabBtn = (active) => `rounded-full border px-3 py-1 text-xs font-medium ${active ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`;
@@ -20,6 +21,15 @@ export default function MarksSheet({
const shown = filter ? highlights.filter((h) => h.color === filter) : highlights;
// Colours in use that aren't one of the picker's (older data) still get listed
const total = highlights.length + bookmarks.length;
const [exportError, setExportError] = useState('');
const exportAs = async (kind) => {
setExportError('');
const model = buildMarksModel({ highlights, bookmarks, colorLabels });
try {
if (kind === 'md') downloadMarksFile(new Blob([marksToMarkdown(model)], { type: 'text/markdown;charset=utf-8' }), 'highlights-and-bookmarks.md');
else downloadMarksFile(await buildMarksDocxBlob(model), 'highlights-and-bookmarks.docx');
} catch { setExportError('Couldn’t make the file. Try again.'); }
};
return (
<Sheet open={open} onClose={onClose} label="Bookmarks and highlights" title={`Bookmarks & highlights${total ? ` (${total})` : ''}`}>
@@ -91,6 +101,14 @@ export default function MarksSheet({
</div>
))
)}
{total > 0 && (
<div className="mt-4 flex flex-wrap items-center gap-2 border-t border-[var(--r-line)] pt-3" role="group" aria-label="Export your marks">
<span className="text-xs text-[var(--r-sub)]">Take them with you</span>
<button type="button" onClick={() => exportAs('md')} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5">Markdown</button>
<button type="button" onClick={() => exportAs('docx')} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5">Word (.docx)</button>
{exportError && <span role="alert" className="text-xs text-[var(--r-danger,#b42318)]">{exportError}</span>}
</div>
)}
</Sheet>
);
}
@@ -0,0 +1,29 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect } from 'vitest';
vi.mock('../../utils/marksExport.js', async (orig) => ({ ...(await orig()), downloadMarksFile: vi.fn() }));
import MarksSheet from './MarksSheet.jsx';
import { downloadMarksFile } from '../../utils/marksExport.js';
const base = { open: true, onClose: () => {}, colorLabels: { '#bbf7d0': 'Promises' }, onSetLabel: () => {}, onJump: () => {}, onRemoveBookmark: () => {}, onRemoveHighlight: () => {} };
const highlights = [{ id: 'h', color: '#bbf7d0', book: 'JHN', bookName: 'John', bookIndex: 42, chapter: 3, verse: 16, startOffset: 0, endOffset: 5, text: 'For God' }];
test('highlights and bookmarks can be exported as Markdown, named by your colour names', async () => {
const user = userEvent.setup();
render(<MarksSheet {...base} bookmarks={[]} highlights={highlights} />);
await user.click(screen.getByRole('button', { name: 'Markdown' }));
const [blob, name] = downloadMarksFile.mock.calls[0];
expect(name).toBe('highlights-and-bookmarks.md');
expect(await blob.text()).toContain('## Promises (green)');
});
test('Word export is offered too, and nothing to export means no buttons', async () => {
const user = userEvent.setup();
const { rerender } = render(<MarksSheet {...base} bookmarks={[]} highlights={highlights} />);
await user.click(screen.getByRole('button', { name: 'Word (.docx)' }));
await vi.waitFor(() => expect(downloadMarksFile).toHaveBeenCalledTimes(2));
expect(downloadMarksFile.mock.calls[1][1]).toBe('highlights-and-bookmarks.docx');
rerender(<MarksSheet {...base} bookmarks={[]} highlights={[]} />);
expect(screen.queryByRole('group', { name: /export your marks/i })).toBeNull();
});
+33 -2
View File
@@ -1,7 +1,8 @@
// 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 { templatePrompt } from '../../utils/studyTemplates.js';
import DictateButton, { appendSpoken } from '../DictateButton.jsx';
import { templateFromChunk, templatePrompt } from '../../utils/studyTemplates.js';
const SECTIONS = [
{ field: 'observation', label: 'Observe', hint: 'What does the text say?',
@@ -15,11 +16,13 @@ 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 }) {
export default function NotesPane({ chunk, updateChunk, allTags, templateId, saveTemplate, notify }) {
const [open, setOpen] = useState(() => {
try { return localStorage.getItem('studyNotesOpen') || 'observation'; } catch { return 'observation'; }
});
const [more, setMore] = useState(false);
const [tplName, setTplName] = useState('');
const [tplOpen, setTplOpen] = useState(false);
const [tagInput, setTagInput] = useState('');
useEffect(() => { setTagInput(''); }, [chunk.id]);
useEffect(() => { try { localStorage.setItem('studyNotesOpen', open || ''); } catch { /* storage unavailable */ } }, [open]);
@@ -53,6 +56,7 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId }) {
<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>
</div>
)}
</div>
@@ -71,7 +75,34 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId }) {
Background / general notes
<textarea value={chunk.generalNotes ?? ''} rows={5} onChange={(e) => updateChunk(chunk.id, { generalNotes: e.target.value })}
placeholder="e.g. author, date, audience, historical context, key themes…" className={`mt-1 ${areaCls}`} />
<span className="mt-1 block"><DictateButton label="Dictate background" onText={(said) => updateChunk(chunk.id, { generalNotes: appendSpoken(chunk.generalNotes, said) })} /></span>
</label>
<label className="block text-xs text-[var(--r-sub)]">
Discussion questions <span className="opacity-70">(one per line; printed on the lesson handout)</span>
<textarea value={chunk.discussionQuestions ?? ''} rows={4} onChange={(e) => updateChunk(chunk.id, { discussionQuestions: e.target.value })}
placeholder={'What does this passage say about God?\nWhere do you see this in your own life?'} className={`mt-1 ${areaCls}`} />
</label>
{saveTemplate && (
<div className="rounded-xl border border-dashed border-[var(--r-line)] p-3">
{!tplOpen ? (
<button type="button" onClick={() => setTplOpen(true)} className="text-xs text-[var(--r-accent)] hover:underline">Save this chunk’s notes as a template…</button>
) : (
<div>
<p className="mb-2 text-xs text-[var(--r-sub)]">What’s written in this chunk’s note boxes becomes the starting text of each new chunk in studies made from the template, along with this study’s prompts.</p>
<div className="flex flex-wrap items-center gap-2">
<input type="text" aria-label="Template name" value={tplName} onChange={(e) => setTplName(e.target.value)} maxLength={40} placeholder="Template name"
className="min-w-0 flex-1 rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm" />
<button type="button" onClick={() => {
const made = templateFromChunk({ name: tplName, chunk, template: templateId });
if (!made) { notify?.('Give it a name, and write something in a note box first.', 2600); return; }
saveTemplate(made); setTplName(''); setTplOpen(false); notify?.(`Saved the template “${made.name}”. Pick it when you start a new study.`, 3200);
}} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5">Save template</button>
<button type="button" onClick={() => setTplOpen(false)} className="text-xs text-[var(--r-sub)] hover:underline">Cancel</button>
</div>
</div>
)}
</div>
)}
<div>
<p className="mb-1.5 text-xs text-[var(--r-sub)]">Tags</p>
<div className="flex flex-wrap items-center gap-1.5">