Teaching: hide-notes-until share, session length check, slides export
- A study share link can hide the owner's notes until a chosen time: the server sends only passages and discussion questions until then, with a banner in the shared view (new share_reveal column, migrated on start). - Outline: a session length with "N of M min planned, X min over" and a marker on the point where the plan runs past it. - Export > Slides: printable 16:9 slides (title, passage, outline points with their verse, discussion questions) for one session or all; light or dark. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
67645cf1df
commit
2c7c7145c4
14 files changed
+303
-33
No files matched your search
+14
-12
@@ -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). */
|
||||
|
||||
@@ -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.' });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -608,6 +608,7 @@ function AppRouter() {
|
||||
return (
|
||||
<div className="min-h-dvh bg-slate-100">
|
||||
<div className="border-b border-slate-200 bg-slate-900 px-4 py-3 text-center text-xs text-slate-300">
|
||||
{sharedProject.hiddenUntil ? `Notes appear ${new Date(sharedProject.hiddenUntil).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })}. For now: the passages and questions. ` : ''}
|
||||
🔗 Read-only shared view —{' '}
|
||||
<a href="/" className="underline hover:text-white">go to Bible Study Project</a>
|
||||
</div>
|
||||
|
||||
@@ -11,21 +11,27 @@ export const expiryText = (expiresAt, now = Date.now()) => {
|
||||
return `Expires ${new Date(expiresAt).toLocaleDateString()} (${days} day${days === 1 ? '' : 's'} left)`;
|
||||
};
|
||||
|
||||
export default function LinkRules({ expiresAt, hasPasscode, busy, onSave }) {
|
||||
// "2026-10-18T09:00" in the person's own time zone <-> ms
|
||||
const toLocalInput = (ms) => { if (!ms) return ''; const d = new Date(ms); const pad = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; };
|
||||
|
||||
// `showReveal` adds "Hide my notes until…" (study links only; an outline link never shows notes).
|
||||
export default function LinkRules({ expiresAt, hasPasscode, busy, onSave, showReveal = false, revealAt = null }) {
|
||||
const [days, setDays] = useState('keep'); // 'keep' | '' | '1' | '7' | '30'
|
||||
const [passcode, setPasscode] = useState('');
|
||||
const dirty = days !== 'keep' || passcode.trim() !== '';
|
||||
const [reveal, setReveal] = useState(null); // null = leave as is; '' = show everything now; else a local date-time
|
||||
const dirty = days !== 'keep' || passcode.trim() !== '' || reveal !== null;
|
||||
|
||||
const save = async () => {
|
||||
const options = {};
|
||||
if (days !== 'keep') options.expiresInDays = days === '' ? null : Number(days);
|
||||
if (passcode.trim()) options.passcode = passcode.trim();
|
||||
if (await onSave(options)) { setDays('keep'); setPasscode(''); }
|
||||
if (reveal !== null) options.revealAt = reveal === '' ? null : new Date(reveal).getTime();
|
||||
if (await onSave(options)) { setDays('keep'); setPasscode(''); setReveal(null); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-3 rounded-xl border border-[var(--r-line)] p-3" role="group" aria-label="Link settings">
|
||||
<p className="text-xs text-[var(--r-sub)]">{expiryText(expiresAt)} · {hasPasscode ? 'Needs a passcode' : 'No passcode'}</p>
|
||||
<p className="text-xs text-[var(--r-sub)]">{expiryText(expiresAt)} · {hasPasscode ? 'Needs a passcode' : 'No passcode'}{showReveal && revealAt && revealAt > Date.now() ? ` · Your notes appear ${new Date(revealAt).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })}` : ''}</p>
|
||||
<div className="mt-2 flex flex-wrap items-end gap-3">
|
||||
<label className="text-xs text-[var(--r-sub)]">Stop working
|
||||
<select value={days} onChange={(e) => setDays(e.target.value)} className={`${field} mt-1 block`}>
|
||||
@@ -36,10 +42,18 @@ export default function LinkRules({ expiresAt, hasPasscode, busy, onSave }) {
|
||||
<label className="text-xs text-[var(--r-sub)]">{hasPasscode ? 'New passcode' : 'Passcode (optional)'}
|
||||
<input type="text" value={passcode} onChange={(e) => setPasscode(e.target.value)} maxLength={100} autoComplete="off" placeholder={hasPasscode ? 'Type to change' : 'None'} className={`${field} mt-1 block w-44`} />
|
||||
</label>
|
||||
{showReveal && (
|
||||
<label className="text-xs text-[var(--r-sub)]">Hide my notes until
|
||||
<input type="datetime-local" value={reveal ?? toLocalInput(revealAt > Date.now() ? revealAt : null)} onChange={(e) => setReveal(e.target.value)} className={`${field} mt-1 block`} />
|
||||
</label>
|
||||
)}
|
||||
{showReveal && (reveal ?? toLocalInput(revealAt > Date.now() ? revealAt : null)) && (
|
||||
<button type="button" onClick={() => setReveal('')} className="text-xs text-[var(--r-sub)] hover:underline">Show everything now</button>
|
||||
)}
|
||||
<button type="button" onClick={save} disabled={!dirty || busy} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-sm hover:bg-black/5 disabled:opacity-50">Save settings</button>
|
||||
{hasPasscode && <button type="button" onClick={() => onSave({ passcode: null })} disabled={busy} className="text-xs text-[var(--r-danger,#b42318)] hover:underline">Remove passcode</button>}
|
||||
</div>
|
||||
<p className="mt-2 text-[11px] text-[var(--r-sub)]">Send the passcode separately from the link. Anyone with both can read it until it expires or you turn it off.</p>
|
||||
<p className="mt-2 text-[11px] text-[var(--r-sub)]">Send the passcode separately from the link. Anyone with both can read it until it expires or you turn it off.{showReveal ? ' Until the time you set, the group sees only the passages and discussion questions; your notes, outline and scripts stay on the server.' : ''}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Export → Slides: a printable slide deck for a session or the whole study (Save as PDF in the print dialog).
|
||||
import { useState } from 'react';
|
||||
import { sessionsFor } from '../utils/handout.js';
|
||||
import { printSlides } from '../utils/slides.js';
|
||||
|
||||
export default function SlidesExport({ project }) {
|
||||
const sessions = sessionsFor(project);
|
||||
const [which, setWhich] = useState('all');
|
||||
const [dark, setDark] = useState(false);
|
||||
const [blocked, setBlocked] = useState(false);
|
||||
if (sessions.length === 0) return null;
|
||||
const go = () => setBlocked(!printSlides(project, which === 'all' ? null : [which], { dark }));
|
||||
return (
|
||||
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Slides">
|
||||
<h2 className="text-sm font-medium">Slides</h2>
|
||||
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">A 16:9 slide for each session’s passage, each outline point (with the verse it quotes) and the discussion questions. Choose “Save as PDF” in the print dialog, then open it in any slide viewer or project it from the PDF. It isn’t a PowerPoint file.</p>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label className="text-xs text-[var(--r-sub)]">Sessions
|
||||
<select value={which} onChange={(e) => setWhich(e.target.value)} className="mt-1 block rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)]">
|
||||
<option value="all">All {sessions.length}</option>
|
||||
{sessions.map((s) => <option key={s.chunk.id} value={s.chunk.id}>{s.number}. {s.reference}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 pb-2 text-xs text-[var(--r-sub)]"><input type="checkbox" checked={dark} onChange={(e) => setDark(e.target.checked)} className="accent-[var(--r-accent)]" /> Dark slides</label>
|
||||
<button type="button" onClick={go} className="rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5">Print / Save PDF</button>
|
||||
</div>
|
||||
{blocked && <p role="alert" className="mt-2 text-xs text-[var(--r-danger,#b42318)]">Allow pop-ups for this site to print the slides.</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -6,14 +6,15 @@ export function useShareLink(projectId) {
|
||||
const [shareToken, setShareToken] = useState(null);
|
||||
const [expiresAt, setExpiresAt] = useState(null);
|
||||
const [hasPasscode, setHasPasscode] = useState(false);
|
||||
const [revealAt, setRevealAt] = useState(null);
|
||||
const [shareBusy, setShareBusy] = useState(false);
|
||||
const [shareCopied, setShareCopied] = useState(false);
|
||||
const [shareError, setShareError] = useState('');
|
||||
|
||||
const apply = (data) => { setShareToken(data.shareToken ?? null); setExpiresAt(data.expiresAt ?? null); setHasPasscode(!!data.hasPasscode); };
|
||||
const apply = (data) => { setShareToken(data.shareToken ?? null); setExpiresAt(data.expiresAt ?? null); setHasPasscode(!!data.hasPasscode); setRevealAt(data.revealAt ?? null); };
|
||||
|
||||
useEffect(() => {
|
||||
setShareToken(null); setExpiresAt(null); setHasPasscode(false); setShareError('');
|
||||
setShareToken(null); setExpiresAt(null); setHasPasscode(false); setRevealAt(null); setShareError('');
|
||||
if (!projectId) return undefined;
|
||||
let active = true;
|
||||
getShareStatus(projectId).then((result) => {
|
||||
@@ -26,7 +27,7 @@ export function useShareLink(projectId) {
|
||||
? `${window.location.origin}${window.location.pathname}?share=${shareToken}`
|
||||
: '';
|
||||
|
||||
// Turns the link on, or changes its rules: { expiresInDays?: number | null, passcode?: string | null }
|
||||
// Turns the link on, or changes its rules: { expiresInDays?: number | null, passcode?: string | null, revealAt?: number | null }
|
||||
const enable = async (options = {}) => {
|
||||
setShareBusy(true);
|
||||
setShareError('');
|
||||
@@ -52,5 +53,5 @@ export function useShareLink(projectId) {
|
||||
});
|
||||
};
|
||||
|
||||
return { shareToken, shareUrl, expiresAt, hasPasscode, shareBusy, shareCopied, shareError, enable, revoke, copy };
|
||||
return { shareToken, shareUrl, expiresAt, hasPasscode, revealAt, shareBusy, shareCopied, shareError, enable, revoke, copy };
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// The Export stage: take the finished study out as a file, or share it read-only.
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { useShareLink } from '../hooks/useShareLink.js';
|
||||
import SlidesExport from '../components/SlidesExport.jsx';
|
||||
import ImportNotes from '../components/ImportNotes.jsx';
|
||||
import HandoutExport from '../components/HandoutExport.jsx';
|
||||
import SessionSchedule from '../components/SessionSchedule.jsx';
|
||||
@@ -53,7 +54,7 @@ export default function ExportPage() {
|
||||
className="min-w-0 flex-1 truncate rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-xs text-[var(--r-sub)]" />
|
||||
<button type="button" onClick={share.copy} className="shrink-0 rounded-lg bg-[var(--r-accent)] px-4 py-2 text-xs font-medium text-[var(--r-on)]">{share.shareCopied ? 'Copied!' : 'Copy'}</button>
|
||||
</div>
|
||||
<LinkRules expiresAt={share.expiresAt} hasPasscode={share.hasPasscode} busy={share.shareBusy} onSave={share.enable} />
|
||||
<LinkRules expiresAt={share.expiresAt} hasPasscode={share.hasPasscode} busy={share.shareBusy} onSave={share.enable} showReveal revealAt={share.revealAt} />
|
||||
{share.shareError && <p role="alert" className="text-xs text-[var(--r-danger,#b42318)]">{share.shareError}</p>}
|
||||
<button type="button" onClick={share.revoke} disabled={share.shareBusy} className="text-xs text-[var(--r-danger,#b42318)] hover:underline">{share.shareBusy ? 'Revoking…' : 'Revoke link'}</button>
|
||||
</div>
|
||||
@@ -64,6 +65,8 @@ export default function ExportPage() {
|
||||
|
||||
<HandoutExport project={project} />
|
||||
|
||||
<SlidesExport project={project} />
|
||||
|
||||
<ImportNotes />
|
||||
|
||||
<SessionSchedule project={project} />
|
||||
|
||||
@@ -11,12 +11,13 @@ import { buildChapterSummary } from '../utils/projectStorage.js';
|
||||
import { compactPassages } from '../utils/passageSummary.js';
|
||||
import { attachableFromChunk, fetchPassageText } from '../utils/passageText.js';
|
||||
import PresentMode from '../components/PresentMode.jsx';
|
||||
import { firstOverPoint, outlineSteps, planStatus } from '../utils/present.js';
|
||||
import IqOutlineAttach from '../components/iq/IqOutlineAttach.jsx';
|
||||
import ShareOutlineSheet from '../components/ShareOutlineSheet.jsx';
|
||||
import { downloadOutlineDocx, downloadOutlineMarkdown, printOutline } from '../utils/outlineExport.js';
|
||||
import {
|
||||
KINDS, STUDY, buildChunkFromNotes, buildFromNotes, notesBuildCount, addAfter, addChild, addRoot, chunkOf, countNodes, flatten, getNode, indent, insertAt, kindLabel, locate,
|
||||
moveNode, newNode, outdent, outlineFor, outlineToText, removeNode, seedFromChunks, seedFromVerses, timeSummary, updateNode, withOutline,
|
||||
moveNode, newNode, outdent, outlineFor, outlineToText, removeNode, seedFromChunks, seedFromVerses, timeSummary, totalMinutes, updateNode, withOutline,
|
||||
} from '../utils/outline.js';
|
||||
|
||||
const iconBtn = 'flex h-7 w-7 items-center justify-center rounded-md text-sm text-[var(--r-sub)] hover:bg-black/5 hover:text-[var(--r-text)] disabled:opacity-30 disabled:hover:bg-transparent';
|
||||
@@ -45,6 +46,11 @@ export default function OutlinePage() {
|
||||
const scopeChunk = scope === STUDY ? null : chunkOf(project, scope);
|
||||
const tree = outlineFor(project, scope);
|
||||
const rows = flatten(tree);
|
||||
// The time you have for a session, and how the planned minutes measure up to it
|
||||
const sessionMinutes = Number(project?.sessionMinutes) || 0;
|
||||
const plan = planStatus(totalMinutes(tree), sessionMinutes);
|
||||
const overPoint = firstOverPoint(outlineSteps(tree), sessionMinutes);
|
||||
const setSessionMinutes = (value) => updateProject((current) => ({ ...current, sessionMinutes: value === '' ? null : Math.max(0, Math.min(600, Math.round(Number(value)))) }));
|
||||
const [pendingFocus, setPendingFocus] = useState(null);
|
||||
const [metaOpen, setMetaOpen] = useState(() => new Set()); // points whose detail fields are showing
|
||||
const [shareOpen, setShareOpen] = useState(false);
|
||||
@@ -160,6 +166,11 @@ export default function OutlinePage() {
|
||||
</select>
|
||||
</label>
|
||||
{timeSummary(tree) && <span className="text-sm text-[var(--r-sub)]">{timeSummary(tree)}</span>}
|
||||
<label className="flex items-center gap-1.5 text-sm text-[var(--r-sub)]">Session length
|
||||
<input type="number" min="0" max="600" inputMode="numeric" aria-label="Session length in minutes" value={project?.sessionMinutes ?? ''} onChange={(e) => setSessionMinutes(e.target.value)}
|
||||
className="w-16 rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1 text-sm text-[var(--r-text)] focus:outline-none" /> min
|
||||
</label>
|
||||
{plan.state !== 'unset' && <span role="status" className={`text-sm ${plan.state === 'over' ? 'font-medium text-[var(--r-danger,#b42318)]' : 'text-[var(--r-sub)]'}`}>{plan.text}</span>}
|
||||
{scopeChunk && <button type="button" onClick={() => openChunk(scope)} className="text-sm text-[var(--r-accent)] hover:underline">Open this chunk in Study</button>}
|
||||
</div>
|
||||
|
||||
@@ -229,10 +240,11 @@ export default function OutlinePage() {
|
||||
/>
|
||||
|
||||
{/* What the point carries, at a glance */}
|
||||
{(node.kind || mins || node.ref || linked) && (
|
||||
{(node.kind || mins || node.ref || linked || overPoint?.id === node.id) && (
|
||||
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 px-2 text-xs text-[var(--r-sub)]">
|
||||
{node.kind && <span className={`${chip} border-[var(--r-accent)] text-[var(--r-accent)]`}>{kindLabel(node.kind)}</span>}
|
||||
{mins && <span>{node.minutes} min</span>}
|
||||
{overPoint?.id === node.id && <span className="font-medium text-[var(--r-danger,#b42318)]">Runs past {sessionMinutes} min here</span>}
|
||||
{node.ref && <span>{node.ref}</span>}
|
||||
{linked && node.chunkId !== scope && <button type="button" onClick={() => openChunk(node.chunkId)} className="text-[var(--r-accent)] hover:underline">Open chunk in Study</button>}
|
||||
</p>
|
||||
|
||||
@@ -41,3 +41,22 @@ export const paceText = (p) => {
|
||||
if (Math.abs(d) < 20) return 'On pace';
|
||||
return d > 0 ? `${formatClock(d)} ahead` : `${formatClock(-d)} behind`;
|
||||
};
|
||||
|
||||
// How the planned minutes compare with the time you have. `limit` is the session length (null/0 = not set).
|
||||
// -> { state: 'unset' | 'under' | 'on' | 'over', diff (minutes, + to spare, - over), text }
|
||||
export function planStatus(total, limit) {
|
||||
const t = Number(total) || 0;
|
||||
const l = Number(limit) || 0;
|
||||
if (!l) return { state: 'unset', diff: 0, text: '' };
|
||||
const diff = l - t;
|
||||
if (t === 0) return { state: 'under', diff, text: `No minutes planned yet for ${l} min` };
|
||||
if (diff === 0) return { state: 'on', diff, text: `${t} of ${l} min planned, right on time` };
|
||||
if (diff > 0) return { state: 'under', diff, text: `${t} of ${l} min planned, ${diff} min to spare` };
|
||||
return { state: 'over', diff, text: `${t} of ${l} min planned, ${-diff} min over` };
|
||||
}
|
||||
|
||||
// The first point that carries the plan past the session length, or null.
|
||||
export function firstOverPoint(steps, limit) {
|
||||
const l = Number(limit) || 0;
|
||||
return l ? steps.find((s) => s.plannedEnd > l) ?? null : null;
|
||||
}
|
||||
@@ -28,3 +28,20 @@ test('pace: ahead before a point\'s window, behind after it, on pace inside it',
|
||||
expect(pacing(outlineSteps([n('x', 'No minutes')]), 0, 10)).toBeNull();
|
||||
expect(paceText(null)).toBe('');
|
||||
});
|
||||
|
||||
import { firstOverPoint, outlineSteps as stepsOf, planStatus } from './present.js';
|
||||
|
||||
test('the plan is compared with the session length', () => {
|
||||
expect(planStatus(40, null).state).toBe('unset');
|
||||
expect(planStatus(0, 45)).toMatchObject({ state: 'under' });
|
||||
expect(planStatus(42, 45)).toEqual({ state: 'under', diff: 3, text: '42 of 45 min planned, 3 min to spare' });
|
||||
expect(planStatus(45, 45).state).toBe('on');
|
||||
expect(planStatus(52, 45)).toEqual({ state: 'over', diff: -7, text: '52 of 45 min planned, 7 min over' });
|
||||
});
|
||||
|
||||
test('the first point that runs past the session length is found', () => {
|
||||
const tree = [{ id: 'a', text: 'A', minutes: 20, children: [] }, { id: 'b', text: 'B', minutes: 20, children: [] }, { id: 'c', text: 'C', minutes: 10, children: [] }];
|
||||
expect(firstOverPoint(stepsOf(tree), 45).id).toBe('c');
|
||||
expect(firstOverPoint(stepsOf(tree), 60)).toBeNull();
|
||||
expect(firstOverPoint(stepsOf(tree), null)).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
// Slides for a session (or the whole study): a title slide, then for each session its passage, one slide per outline
|
||||
// point (with the verse the point quotes) and the discussion questions. Built as a printable 16:9 page set ("Save as
|
||||
// PDF" in the print dialog) from the same sessions, outlines and questions as the lesson handout.
|
||||
import { getChunkVerseEntries } from './bibleUtils.jsx';
|
||||
import { flatten } from './outline.js';
|
||||
import { compactPassages } from './passageSummary.js';
|
||||
import { buildChapterSummary } from './projectStorage.js';
|
||||
import { outlineForSession, questionsFor, sessionsFor } from './handout.js';
|
||||
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
|
||||
// -> [{ kind: 'title' | 'section' | 'passage' | 'point' | 'questions', ... }] in the order they are shown.
|
||||
// ids: the chunk ids to include, or null for every session.
|
||||
export function buildSlides(project, ids = null) {
|
||||
const slides = [{ kind: 'title', title: project.title ?? '', subtitle: compactPassages(buildChapterSummary(project)) }];
|
||||
for (const session of sessionsFor(project).filter((s) => !ids || ids.includes(s.chunk.id))) {
|
||||
const { chunk, chapterIndex, number, reference } = session;
|
||||
const name = (chunk.episodeTitle ?? '').trim();
|
||||
slides.push({ kind: 'section', title: name || reference, subtitle: name ? reference : '', number: (chunk.episodeNumber ?? '').toString().trim() || number });
|
||||
const verses = getChunkVerseEntries(project, chapterIndex, chunk);
|
||||
if (verses.length) slides.push({ kind: 'passage', reference, translation: project.translation ?? 'BSB', verses: verses.map((v) => ({ number: v.number, text: v.text })) });
|
||||
for (const { node, label, depth } of flatten(outlineForSession(project, chunk))) {
|
||||
slides.push({ kind: 'point', label, depth, text: node.text ?? '', ref: node.ref ?? '', quote: node.scripture?.text ? { text: node.scripture.text, ref: node.scripture.ref ?? '' } : null });
|
||||
}
|
||||
const q = questionsFor(chunk);
|
||||
if (q.items.length) slides.push({ kind: 'questions', title: q.kind === 'questions' ? 'Discussion' : 'Think about it', items: q.items });
|
||||
}
|
||||
return slides;
|
||||
}
|
||||
|
||||
// Longer text gets a smaller size so it stays on the slide.
|
||||
const sizeFor = (chars, steps) => steps.find(([max]) => chars <= max)?.[1] ?? steps[steps.length - 1][1];
|
||||
|
||||
function slideHtml(s) {
|
||||
if (s.kind === 'title') return `<section class="slide center"><h1>${esc(s.title)}</h1>${s.subtitle ? `<p class="sub">${esc(s.subtitle)}</p>` : ''}</section>`;
|
||||
if (s.kind === 'section') return `<section class="slide center"><p class="kicker">Session ${esc(s.number)}</p><h1>${esc(s.title)}</h1>${s.subtitle ? `<p class="sub">${esc(s.subtitle)}</p>` : ''}</section>`;
|
||||
if (s.kind === 'passage') {
|
||||
const chars = s.verses.reduce((n, v) => n + v.text.length, 0);
|
||||
const size = sizeFor(chars, [[300, 34], [550, 28], [900, 23], [1400, 19], [Infinity, 16]]);
|
||||
return `<section class="slide"><p class="kicker">${esc(s.reference)} · ${esc(s.translation)}</p><div class="passage" style="font-size:${size}pt">${s.verses.map((v) => `<p><sup>${esc(v.number)}</sup> ${esc(v.text)}</p>`).join('')}</div></section>`;
|
||||
}
|
||||
if (s.kind === 'point') {
|
||||
const chars = s.text.length + (s.quote?.text.length ?? 0);
|
||||
const size = sizeFor(chars, [[80, 46], [160, 38], [300, 30], [Infinity, 24]]);
|
||||
return `<section class="slide"><p class="kicker">${s.depth ? 'Point ' : ''}${esc(s.label)}</p><h2 style="font-size:${size}pt">${esc(s.text || '(untitled)')}</h2>${s.ref ? `<p class="sub">${esc(s.ref)}</p>` : ''}${s.quote ? `<blockquote style="font-size:${Math.max(16, size - 14)}pt">“${esc(s.quote.text)}”${s.quote.ref ? ` <span>— ${esc(s.quote.ref)}</span>` : ''}</blockquote>` : ''}</section>`;
|
||||
}
|
||||
return `<section class="slide"><p class="kicker">${esc(s.title)}</p><ol class="qs">${s.items.map((i) => `<li>${esc(i)}</li>`).join('')}</ol></section>`;
|
||||
}
|
||||
|
||||
export function buildSlidesHtml(project, ids = null, { dark = false } = {}) {
|
||||
const bg = dark ? '#16130f' : '#faf7f0';
|
||||
const fg = dark ? '#f2efe8' : '#2b2620';
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><title>${esc(project.title)} — slides</title><style>
|
||||
@page { size: 13.333in 7.5in; margin: 0; }
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; background: ${bg}; color: ${fg}; font-family: Georgia, 'Times New Roman', serif; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
.slide { width: 13.333in; height: 7.5in; padding: 0.7in 0.9in; page-break-after: always; break-after: page; display: flex; flex-direction: column; justify-content: center; overflow: hidden; background: ${bg}; }
|
||||
.slide:last-child { page-break-after: auto; break-after: auto; }
|
||||
.center { align-items: center; text-align: center; }
|
||||
.kicker { margin: 0 0 .5em; font: 600 14pt -apple-system, Helvetica, Arial, sans-serif; letter-spacing: .16em; text-transform: uppercase; color: #a84e27; }
|
||||
h1 { margin: 0; font-size: 54pt; font-weight: 600; line-height: 1.15; }
|
||||
h2 { margin: 0; font-weight: 600; line-height: 1.25; }
|
||||
.sub { margin: .6em 0 0; font: 20pt -apple-system, Helvetica, Arial, sans-serif; opacity: .7; }
|
||||
.passage p { margin: .35em 0; line-height: 1.45; } sup { color: #a84e27; font: 60% -apple-system, Helvetica, Arial, sans-serif; }
|
||||
blockquote { margin: .9em 0 0; padding-left: .8em; border-left: 6px solid #a84e27; font-style: italic; line-height: 1.4; } blockquote span { font-style: normal; opacity: .65; }
|
||||
.qs { font-size: 30pt; line-height: 1.4; padding-left: 1.2em; margin: 0; } .qs li { margin: .5em 0; }
|
||||
@media screen { body { padding: 1em; } .slide { margin: 0 auto 1em; box-shadow: 0 2px 12px rgba(0,0,0,.2); } }
|
||||
</style></head><body>${buildSlides(project, ids).map(slideHtml).join('')}</body></html>`;
|
||||
}
|
||||
|
||||
export function printSlides(project, ids = null, options) {
|
||||
const win = window.open('', '_blank');
|
||||
if (!win) return false;
|
||||
win.document.write(buildSlidesHtml(project, ids, options));
|
||||
win.document.close();
|
||||
win.onload = () => { win.focus(); win.print(); };
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { test, expect } from 'vitest';
|
||||
import { buildSlides, buildSlidesHtml } from './slides.js';
|
||||
|
||||
const project = () => ({
|
||||
id: 'p', title: 'John <study>', translation: 'BSB', outline: [],
|
||||
chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: [{ number: 16, text: 'For God so loved the world.' }, { number: 17, text: 'God sent not his Son to condemn.' }], chunks: [
|
||||
{ id: 'a', startVerse: 16, endVerse: 17, episodeTitle: 'God so loved', discussionQuestions: 'Why did he give?\nWho is "the world"?',
|
||||
outline: [{ id: 'n1', text: 'Love gives', children: [{ id: 'n2', text: 'He gave', scripture: { text: 'For God so loved', ref: 'John 3:16' }, children: [] }] }] },
|
||||
{ id: 'b', startVerse: 17, endVerse: 17, application: 'Trust him.', outline: [] },
|
||||
] }],
|
||||
});
|
||||
|
||||
test('slides run title, then per session: section, passage, points, questions', () => {
|
||||
const kinds = buildSlides(project()).map((s) => s.kind);
|
||||
expect(kinds).toEqual(['title', 'section', 'passage', 'point', 'point', 'questions', 'section', 'passage', 'questions']);
|
||||
const slides = buildSlides(project());
|
||||
expect(slides[1]).toMatchObject({ title: 'God so loved', subtitle: 'John 3:16–17' });
|
||||
expect(slides[4]).toMatchObject({ depth: 1, text: 'He gave', quote: { ref: 'John 3:16' } });
|
||||
expect(slides[8]).toMatchObject({ title: 'Think about it', items: ['Trust him.'] }); // Application notes stand in for questions
|
||||
});
|
||||
|
||||
test('one session only', () => {
|
||||
const kinds = buildSlides(project(), ['b']).map((s) => s.kind);
|
||||
expect(kinds).toEqual(['title', 'section', 'passage', 'questions']);
|
||||
});
|
||||
|
||||
test('the printable page has a 16:9 slide per entry, escapes text, and can be dark', () => {
|
||||
const html = buildSlidesHtml(project());
|
||||
expect(html.match(/class="slide/g)).toHaveLength(9);
|
||||
expect(html).toContain('John <study>');
|
||||
expect(html).toContain('size: 13.333in 7.5in');
|
||||
expect(html).not.toContain('<study>');
|
||||
expect(buildSlidesHtml(project(), null, { dark: true })).toContain('#16130f');
|
||||
});
|
||||
Reference in new issue
Block a user