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
@@ -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