diff --git a/server/db.js b/server/db.js index 2687dd6..63b95a6 100644 --- a/server/db.js +++ b/server/db.js @@ -140,7 +140,7 @@ export function initDb() { db.exec('ALTER TABLE projects ADD COLUMN outline_share_token TEXT'); } // Share links can expire and be protected by a passcode. - for (const col of ['share_expires INTEGER', 'share_pass TEXT', 'outline_share_expires INTEGER', 'outline_share_pass TEXT']) { + for (const col of ['share_expires INTEGER', 'share_pass TEXT', 'share_reveal INTEGER', 'outline_share_expires INTEGER', 'outline_share_pass TEXT']) { if (!projectCols.some((c) => c.name === col.split(' ')[0])) db.exec(`ALTER TABLE projects ADD COLUMN ${col}`); } db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_outline_share_token ON projects(outline_share_token) WHERE outline_share_token IS NOT NULL'); @@ -780,7 +780,7 @@ export function setShareToken(id, userId, token) { /** Revokes a project's share link, scoped to userId. */ export function clearShareToken(id, userId) { - db.prepare('UPDATE projects SET share_token = NULL, share_expires = NULL, share_pass = NULL WHERE id = ? AND user_id = ?').run(id, userId); + db.prepare('UPDATE projects SET share_token = NULL, share_expires = NULL, share_pass = NULL, share_reveal = NULL WHERE id = ? AND user_id = ?').run(id, userId); } // ---- read-only outline links: a separate token, so sharing an outline never shares the whole study ---- @@ -800,14 +800,14 @@ export function clearOutlineShareToken(id, userId) { // ---- expiry and passcode for either kind of read-only link (outline = the outline-only link) ---- const shareCols = (outline) => (outline - ? { token: 'outline_share_token', expires: 'outline_share_expires', pass: 'outline_share_pass' } - : { token: 'share_token', expires: 'share_expires', pass: 'share_pass' }); + ? { token: 'outline_share_token', expires: 'outline_share_expires', pass: 'outline_share_pass', reveal: 'NULL' } + : { token: 'share_token', expires: 'share_expires', pass: 'share_pass', reveal: 'share_reveal' }); -/** { token, expiresAt, hasPasscode } for the owner's link settings. */ +/** { token, expiresAt, hasPasscode, revealAt } for the owner's link settings (revealAt: notes stay hidden until then). */ export function getShareSettings(id, userId, outline = false) { const c = shareCols(outline); - const row = db.prepare(`SELECT ${c.token} AS token, ${c.expires} AS expiresAt, ${c.pass} AS pass FROM projects WHERE id = ? AND user_id = ?`).get(id, userId); - return row ? { token: row.token ?? null, expiresAt: row.expiresAt ?? null, hasPasscode: !!row.pass } : null; + const row = db.prepare(`SELECT ${c.token} AS token, ${c.expires} AS expiresAt, ${c.pass} AS pass, ${c.reveal} AS revealAt FROM projects WHERE id = ? AND user_id = ?`).get(id, userId); + return row ? { token: row.token ?? null, expiresAt: row.expiresAt ?? null, hasPasscode: !!row.pass, revealAt: row.revealAt ?? null } : null; } /** The stored passcode hash of a link (kept when only the expiry is changed). */ @@ -816,18 +816,20 @@ export function getSharePassHash(id, userId, outline = false) { return db.prepare(`SELECT ${c.pass} AS pass FROM projects WHERE id = ? AND user_id = ?`).get(id, userId)?.pass ?? null; } -/** Sets expiry (ms timestamp or null) and passcode hash (or null) on an existing link. */ -export function setShareSettings(id, userId, { expiresAt, passHash }, outline = false) { +/** Sets expiry (ms timestamp or null), passcode hash (or null) and, for a study link, when its notes appear (or null). */ +export function setShareSettings(id, userId, { expiresAt, passHash, revealAt }, outline = false) { const c = shareCols(outline); - return db.prepare(`UPDATE projects SET ${c.expires} = ?, ${c.pass} = ? WHERE id = ? AND user_id = ?`).run(expiresAt ?? null, passHash ?? null, id, userId).changes > 0; + const changed = db.prepare(`UPDATE projects SET ${c.expires} = ?, ${c.pass} = ? WHERE id = ? AND user_id = ?`).run(expiresAt ?? null, passHash ?? null, id, userId).changes > 0; + if (changed && !outline && revealAt !== undefined) db.prepare('UPDATE projects SET share_reveal = ? WHERE id = ? AND user_id = ?').run(revealAt ?? null, id, userId); + return changed; } /** Public lookup for a link: the project plus the link's rules, or null for an unknown token. */ export function getSharedByToken(token, outline = false) { const c = shareCols(outline); - const row = db.prepare(`SELECT data, ${c.expires} AS expiresAt, ${c.pass} AS passHash FROM projects WHERE ${c.token} = ?`).get(token); + const row = db.prepare(`SELECT data, ${c.expires} AS expiresAt, ${c.pass} AS passHash, ${c.reveal} AS revealAt FROM projects WHERE ${c.token} = ?`).get(token); if (!row) return null; - try { return { project: JSON.parse(row.data), expiresAt: row.expiresAt ?? null, passHash: row.passHash ?? null }; } catch { return null; } + try { return { project: JSON.parse(row.data), expiresAt: row.expiresAt ?? null, passHash: row.passHash ?? null, revealAt: row.revealAt ?? null }; } catch { return null; } } /** Public lookup: the project data behind an outline link (callers must only ever send the outlines on). */ diff --git a/server/routes/sharing.js b/server/routes/sharing.js index ee8f68d..071658e 100644 --- a/server/routes/sharing.js +++ b/server/routes/sharing.js @@ -2,7 +2,7 @@ import { randomUUID } from 'crypto'; import { acceptEditInvite, clearOutlineShareToken, clearShareToken, countOpenEditInvites, createEditInvite, getSharePassHash, getShareSettings, getSharedByToken, isProjectOwner, listCollaborators, removeCollaborator, setOutlineShareToken, setShareSettings, setShareToken } from '../db.js'; import { requireAuth } from '../auth.js'; -import { checkShareAccess, expiryFromDays, hashPasscode } from '../shareLinks.js'; +import { checkShareAccess, expiryFromDays, hashPasscode, hideUntilReveal, revealFromInput } from '../shareLinks.js'; import { buildOutlinePayload } from '../outlineShare.js'; import { TOO_MANY, isLimited, recordFailure } from '../limiter.js'; @@ -16,7 +16,7 @@ export function registerSharingRoutes(app) { try { const settings = getShareSettings(req.params.id, req.session.userId); if (!settings) return res.status(404).json({ error: 'Project not found.' }); - res.json({ shareToken: settings.token, expiresAt: settings.expiresAt, hasPasscode: settings.hasPasscode }); + res.json({ shareToken: settings.token, expiresAt: settings.expiresAt, hasPasscode: settings.hasPasscode, revealAt: settings.revealAt }); } catch (err) { console.error('GET /api/projects/:id/share error:', err); res.status(500).json({ error: 'Failed to load share status.' }); @@ -37,8 +37,10 @@ export function registerSharingRoutes(app) { // a blank passcode field means "keep it"; null clears; a string sets const current = getSharePassHash(req.params.id, userId, outline); const passHash = 'passcode' in body ? (body.passcode === null ? null : await hashPasscode(body.passcode) ?? current) : current; - setShareSettings(req.params.id, userId, { expiresAt, passHash }, outline); - res.json({ shareToken: token, expiresAt, hasPasscode: !!passHash }); + // only a study link can hide its notes until a time; a past or empty time turns hiding off + const revealAt = outline ? null : ('revealAt' in body ? revealFromInput(body.revealAt) : settings.revealAt); + setShareSettings(req.params.id, userId, { expiresAt, passHash, revealAt }, outline); + res.json({ shareToken: token, expiresAt, hasPasscode: !!passHash, revealAt }); } catch (err) { console.error(`POST share (${outline ? 'outline' : 'study'}) error:`, err); res.status(500).json({ error: 'Failed to enable sharing.' }); @@ -123,14 +125,14 @@ export function registerSharingRoutes(app) { res.status(access.status).json({ error: access.error, needsPasscode: !!access.needsPasscode, expired: access.status === 410 }); return null; } - return found.project; + return found; } // GET /api/share-outline/:token — PUBLIC, no login required (a passcode may be needed) app.get('/api/share-outline/:token', async (req, res) => { try { - const project = await openSharedLink(req, res, true); - if (project) res.json(buildOutlinePayload(project)); + const found = await openSharedLink(req, res, true); + if (found) res.json(buildOutlinePayload(found.project)); } catch (err) { console.error('GET /api/share-outline/:token error:', err); res.status(500).json({ error: 'Failed to load the shared outline.' }); @@ -140,8 +142,8 @@ export function registerSharingRoutes(app) { // GET /api/share/:token — PUBLIC, no login required: fetch a shared project read-only (a passcode may be needed) app.get('/api/share/:token', async (req, res) => { try { - const project = await openSharedLink(req, res, false); - if (project) res.json(project); + const found = await openSharedLink(req, res, false); + if (found) res.json(hideUntilReveal(found.project, found.revealAt)); } catch (err) { console.error('GET /api/share/:token error:', err); res.status(500).json({ error: 'Failed to load shared project.' }); diff --git a/server/shareLinks.js b/server/shareLinks.js index 076dfb7..00c548e 100644 --- a/server/shareLinks.js +++ b/server/shareLinks.js @@ -25,3 +25,27 @@ export async function checkShareAccess({ expiresAt, passHash, supplied }, now = if (!(await bcrypt.compare(given, passHash))) return { ok: false, status: 401, needsPasscode: true, error: 'That passcode is not right.' }; return { ok: true }; } + +// "Hide my notes until…": before the reveal time a shared study shows only the passages and the discussion questions +// (no notes, outline, script, words or group notes), and says when the rest appears. The strip is done here, on the +// server, so the hidden text never reaches the browser. After the time (or with none set) the study is sent whole. +const OPEN_CHUNK_FIELDS = ['id', 'startVerse', 'endVerse', 'spilloverEndVerse', 'episodeNumber', 'episodeTitle', 'discussionQuestions']; + +export function hideUntilReveal(project, revealAt, now = Date.now()) { + if (!revealAt || revealAt <= now || !project || typeof project !== 'object') return project; + return { + id: project.id, title: project.title, translation: project.translation, series: project.series, outline: [], + hiddenUntil: revealAt, + chapters: (Array.isArray(project.chapters) ? project.chapters : []).map((ch) => ({ + book: ch.book, bookAbbrev: ch.bookAbbrev, chapter: ch.chapter, verses: ch.verses, + chunks: (Array.isArray(ch.chunks) ? ch.chunks : []).map((chunk) => Object.fromEntries(OPEN_CHUNK_FIELDS.filter((f) => f in chunk).map((f) => [f, chunk[f]]))), + })), + }; +} + +// A reveal time from the browser -> ms in the future (up to a year), or null for "no hiding" +export function revealFromInput(value, now = Date.now()) { + const n = Number(value); + if (!value || !Number.isFinite(n) || n <= now) return null; + return Math.min(n, now + 365 * 86_400_000); +} diff --git a/server/shareLinks.test.js b/server/shareLinks.test.js index fdca999..1e47bff 100644 --- a/server/shareLinks.test.js +++ b/server/shareLinks.test.js @@ -31,3 +31,36 @@ test('an expired link is reported as expired even before the passcode is checked const passHash = await hashPasscode('x'); expect(await checkShareAccess({ passHash, expiresAt: NOW - 5, supplied: 'x' }, NOW)).toMatchObject({ status: 410 }); }); + +import { hideUntilReveal, revealFromInput } from './shareLinks.js'; + +const study = () => ({ + id: 's1', title: 'John', translation: 'BSB', outline: [{ id: 'n', text: 'Point' }], + chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: [{ number: 16, text: 'For God so loved.' }], + chunks: [{ id: 'c', startVerse: 16, endVerse: 18, discussionQuestions: 'Why?', observation: 'secret', interpretation: 'secret', application: 'secret', finalScript: 'secret', generalNotes: 'secret', greekWords: [{ id: 'w' }], teachBack: 'secret', recalls: [{ text: 'secret' }] }] }], +}); + +test('before the reveal time a shared study keeps the passages and questions and drops every note', () => { + const now = 1_000; + const out = hideUntilReveal(study(), now + 5_000, now); + expect(out.hiddenUntil).toBe(now + 5_000); + expect(out.outline).toEqual([]); + expect(out.chapters[0].verses).toHaveLength(1); + expect(out.chapters[0].chunks[0]).toEqual({ id: 'c', startVerse: 16, endVerse: 18, discussionQuestions: 'Why?' }); + expect(JSON.stringify(out)).not.toContain('secret'); +}); + +test('after the reveal time, or with none set, the whole study is sent', () => { + const full = study(); + expect(hideUntilReveal(full, 500, 1_000)).toBe(full); + expect(hideUntilReveal(full, null, 1_000)).toBe(full); +}); + +test('a reveal time must be in the future, and no more than a year away', () => { + const now = 1_000_000; + expect(revealFromInput(null, now)).toBeNull(); + expect(revealFromInput(now - 1, now)).toBeNull(); + expect(revealFromInput('nope', now)).toBeNull(); + expect(revealFromInput(now + 60_000, now)).toBe(now + 60_000); + expect(revealFromInput(now + 10 * 365 * 86_400_000, now)).toBe(now + 365 * 86_400_000); +}); diff --git a/src/App.jsx b/src/App.jsx index 23d9e31..10ffb59 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -11,6 +11,7 @@ import Tooltips from './components/Tooltips.jsx'; import { useAudio } from './hooks/useAudio.js'; import { useAuth } from './hooks/useAuth.js'; import { useBibleReader } from './hooks/useBibleReader.js'; +import { isAudienceWindow } from './utils/presentSync.js'; import { useProjectLibrary } from './hooks/useProjectLibrary.js'; import { useHistoryNav } from './hooks/useHistoryNav.js'; import { usePrayers } from './hooks/usePrayers.js'; @@ -37,6 +38,8 @@ const ImportPage = lazy(() => import('./pages/ImportPage.jsx')); const ReaderPage = lazy(() => import('./pages/ReaderPage.jsx')); const SettingsPage = lazy(() => import('./pages/SettingsPage.jsx')); const SetupPage = lazy(() => import('./pages/SetupPage.jsx')); +const AudienceView = lazy(() => import('./components/AudienceView.jsx')); +const InsightsPage = lazy(() => import('./pages/InsightsPage.jsx')); const StudyPage = lazy(() => import('./pages/StudyPage.jsx')); const OutlinePage = lazy(() => import('./pages/OutlinePage.jsx')); const ScriptPage = lazy(() => import('./pages/ScriptPage.jsx')); @@ -166,8 +169,8 @@ const AppInner = () => { // The browser tab and history show where you are ("Romans 8 · Bible Study", "Luke — Outline · Bible Study") useEffect(() => { const names = { - home: 'My Studies', setup: 'Chunks', study: 'Study', outline: 'Outline', script: 'Script', export: 'Export', - import: 'Import', pronunciation: 'Pronunciation Guide', settings: 'Settings', admin: 'Admin', 'reading-plans': 'Reading Plans', prayer: 'Prayer journal', + home: 'My Studies', setup: 'Chunks', study: 'Study', outline: 'Outline', script: 'Script', export: 'Finish', + import: 'Import', pronunciation: 'Pronunciation Guide', settings: 'Settings', admin: 'Admin', 'reading-plans': 'Reading Plans', prayer: 'Prayer journal', insights: 'Insights', }; let page = names[currentPage] ?? ''; if (currentPage === 'reader') { @@ -355,12 +358,13 @@ const AppInner = () => { setProject((current) => (current ? updater(current) : current)); }; - const updateChunk = (chunkId, patch) => { + // `touch: false` saves a change without counting it as an edit (so it doesn't reset "last edited" for recalls and summaries). + const updateChunk = (chunkId, patch, { touch = true } = {}) => { updateProject((current) => ({ ...current, chapters: current.chapters.map((ch) => ({ ...ch, - chunks: ch.chunks.map((c) => (c.id === chunkId ? { ...c, ...patch, editedAt: Date.now() } : c)), + chunks: ch.chunks.map((c) => (c.id === chunkId ? { ...c, ...patch, ...(touch ? { editedAt: Date.now() } : {}) } : c)), })), })); }; @@ -584,6 +588,9 @@ function AppRouter() { // A read-only outline link: no sign-in, nothing editable if (sharedOutlineToken) return ; + // The second window for presenting on a projector: no sign-in, it only shows what the presenter window sends. + if (isAudienceWindow()) return ; + if (sharedViewToken) { if (needsPasscode) return ; if (sharedError) { @@ -606,6 +613,7 @@ function AppRouter() { return (
+ {sharedProject.hiddenUntil ? `Notes appear ${new Date(sharedProject.hiddenUntil).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })}. For now: the passages and questions. ` : ''} 🔗 Read-only shared view —{' '} go to Bible Study Project
@@ -629,6 +637,7 @@ function AppRouter() { if (currentPage === 'prayer') return ; if (currentPage === 'reader') return ; if (currentPage === 'import') return ; + if (currentPage === 'insights') return ; if (currentPage === 'study') return ; if (currentPage === 'outline') return ; if (currentPage === 'script') return ; diff --git a/src/components/AudienceView.jsx b/src/components/AudienceView.jsx new file mode 100644 index 0000000..2f52dc4 --- /dev/null +++ b/src/components/AudienceView.jsx @@ -0,0 +1,56 @@ +// The audience window for two-screen presenting: drag it to the projector or second display and press F (or click) +// for full screen. It only draws what the presenter window sends, which never includes private notes. +import { useEffect, useState } from 'react'; +import StepFace from './StepFace.jsx'; +import { openChannel } from '../utils/presentSync.js'; + +export default function AudienceView() { + const [state, setState] = useState(null); + const [ended, setEnded] = useState(false); + const [hint, setHint] = useState(true); + + useEffect(() => { + document.title = 'Audience display'; + const channel = openChannel(); + if (!channel) return undefined; + channel.onmessage = (e) => { + if (e.data?.type === 'state') { setState(e.data); setEnded(false); } + else if (e.data?.type === 'end') setEnded(true); + }; + channel.postMessage({ type: 'hello' }); // ask a presenter already running to send what it is showing + return () => channel.close(); + }, []); + + useEffect(() => { + const fullscreen = () => { if (document.fullscreenElement) document.exitFullscreen?.(); else document.documentElement.requestFullscreen?.().catch(() => {}); }; + const onKey = (e) => { if (e.key === 'f' || e.key === 'F') fullscreen(); }; + window.addEventListener('keydown', onKey); + const timer = window.setTimeout(() => setHint(false), 8000); + return () => { window.removeEventListener('keydown', onKey); window.clearTimeout(timer); }; + }, []); + + const dark = state?.dark ?? true; + const colors = dark ? 'bg-[#111] text-[#f2efe8]' : 'bg-[#faf7f0] text-[#2b2620]'; + const fontSize = `${state?.fontPx ?? 40}px`; + let body; + if (!state || ended) { + body =

{ended ? 'The presentation has ended.' : 'Waiting for the presenter…'}

; + } else if (state.blank) { + body = null; + } else if (state.mode === 'outline' && state.step) { + body = ; + } else { + body = ( +
+

{state.title}

+ {state.subtitle &&

{state.subtitle}

} +
+ ); + } + return ( +
document.documentElement.requestFullscreen?.().catch(() => {})}> + {body} + {hint && !state?.blank &&

Press F or click for full screen

} +
+ ); +} 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 (

Lesson handout

@@ -20,6 +21,11 @@ export default function HandoutExport({ project }) { {sessions.map((s) => )} +
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.

+ + {error &&

{error}

} + {preview && sections.length > 0 && ( +
+

{file}: {sections.length} section{sections.length === 1 ? '' : 's'} found.

+
    + {preview.matched.map((m) =>
  • ✓ {m.heading}: {m.fields.join(', ')}
  • )} + {preview.unmatched.map((h) =>
  • No chunk for {h}, skipped
  • )} +
+ + +
+ )} +
+ ); +} diff --git a/src/components/LinkRules.jsx b/src/components/LinkRules.jsx index 285aa9a..62bdd17 100644 --- a/src/components/LinkRules.jsx +++ b/src/components/LinkRules.jsx @@ -11,21 +11,27 @@ export const expiryText = (expiresAt, now = Date.now()) => { return `Expires ${new Date(expiresAt).toLocaleDateString()} (${days} day${days === 1 ? '' : 's'} left)`; }; -export default function LinkRules({ expiresAt, hasPasscode, busy, onSave }) { +// "2026-10-18T09:00" in the person's own time zone <-> ms +const toLocalInput = (ms) => { if (!ms) return ''; const d = new Date(ms); const pad = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; }; + +// `showReveal` adds "Hide my notes until…" (study links only; an outline link never shows notes). +export default function LinkRules({ expiresAt, hasPasscode, busy, onSave, showReveal = false, revealAt = null }) { const [days, setDays] = useState('keep'); // 'keep' | '' | '1' | '7' | '30' const [passcode, setPasscode] = useState(''); - const dirty = days !== 'keep' || passcode.trim() !== ''; + const [reveal, setReveal] = useState(null); // null = leave as is; '' = show everything now; else a local date-time + const dirty = days !== 'keep' || passcode.trim() !== '' || reveal !== null; const save = async () => { const options = {}; if (days !== 'keep') options.expiresInDays = days === '' ? null : Number(days); if (passcode.trim()) options.passcode = passcode.trim(); - if (await onSave(options)) { setDays('keep'); setPasscode(''); } + if (reveal !== null) options.revealAt = reveal === '' ? null : new Date(reveal).getTime(); + if (await onSave(options)) { setDays('keep'); setPasscode(''); setReveal(null); } }; return (
-

{expiryText(expiresAt)} · {hasPasscode ? 'Needs a passcode' : 'No passcode'}

+

{expiryText(expiresAt)} · {hasPasscode ? 'Needs a passcode' : 'No passcode'}{showReveal && revealAt && revealAt > Date.now() ? ` · Your notes appear ${new Date(revealAt).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })}` : ''}

+ {showReveal && ( + + )} + {showReveal && (reveal ?? toLocalInput(revealAt > Date.now() ? revealAt : null)) && ( + + )} {hasPasscode && }
-

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

; + } + const next = status.missing[0]; + return ( +

+ 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.'} +

+ ); const colors = dark ? 'bg-[#111] text-[#f2efe8]' : 'bg-[#faf7f0] text-[#2b2620]'; const fontSize = `${SIZES[size]}px`; const total = steps.length ? steps[steps.length - 1].plannedEnd : 0; @@ -102,29 +149,73 @@ export default function PresentMode({ title, subtitle = '', mode, script = '', t )} + {typeof BroadcastChannel !== 'undefined' ? ( +
+ {audienceOpen ? ( + <> + + {mode === 'outline' && ( + + + + + )} + + ) : ( + <> + + {mode === 'outline' && } + + )} +
+ ) : null} {mode === 'script' ? ( + <> + {banner}
{script.trim() ?
{script} :

There’s no script to present yet. Write one on the Script page.

}
+ ) : (
-
- {step ? ( -
-

{step.kind ? `${step.kind} · ` : ''}Point {index + 1} of {steps.length}{step.minutes ? ` · ${step.minutes} min` : ''}

-

{step.label}{step.text || '(untitled)'}

- {step.ref &&

{step.ref}

} - {step.scripture?.text &&
“{step.scripture.text}” — {step.scripture.ref}
} - {step.note &&

{step.note}

} - {step.attachments.length > 0 &&

{step.attachments.map((a) => `${a.type === 'word' ? 'Word' : 'See'}: ${a.label}`).join(' · ')}

} + {popupBlocked &&

Your browser blocked the audience window. Allow pop-ups for this site, then try again.

} + {banner} + {audienceOpen && step ? ( + // Presenter view: what the audience sees, your private notes, and what comes next +
+
+

The audience sees

+
- ) :

There’s no outline to present yet. Build one on the Outline page.

} -
+
+

Only you see this

+ {step.note.trim() + ?

{step.note}

+ :

No speaker notes for this point. Add them under the point’s “⋯” details on the Outline page.

} + {steps[index + 1] && ( +
+

Next

+

{steps[index + 1].label}{steps[index + 1].text || '(untitled)'}

+ {steps[index + 1].minutes > 0 &&

{steps[index + 1].minutes} min

} +
+ )} +
+
+ ) : ( +
+ {step ? ( + <> + + {showNotes && step.note &&

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.

+
+ + + +
+ {blocked &&

Allow pop-ups for this site to print the slides.

} +
+ ); +} 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 ( +
+

{step.kind ? `${step.kind} · ` : ''}Point {index + 1} of {total}{minutes ? ` · ${minutes} min` : ''}

+

{step.label}{step.text || '(untitled)'}

+ {step.ref &&

{step.ref}

} + {step.scripture?.text &&
“{step.scripture.text}” — {step.scripture.ref}
} + {note &&

{note}

} + {step.attachments?.length > 0 &&

{step.attachments.map((a) => `${a.type === 'word' ? 'Word' : 'See'}: ${a.label}`).join(' · ')}

} +
+ ); +} 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() { ))}
+