Invite-only sign-up, present mode, series, handouts, templates, dictation, progress, sharing
Build and Push Docker Image / build-and-push (push) Successful in 6m47s
Build and Push Docker Image / build-and-push (push) Successful in 6m47s
- Admin: sign-up Open / Invite only / Closed with single-use invite links - Present mode for scripts (auto-scroll) and outlines (step through, pace vs planned minutes) - Series: group studies on the dashboard, filter by series, calendar file for a study's sessions - Lesson handout: printable page per session (passage, outline, questions, writing lines) - Save a chunk's notes as your own study template (synced) - Voice dictation for notes and scripts (browser speech recognition) - Reading progress: map of the 66 books and a calendar of days read, synced and merged across devices - Export highlights and bookmarks as Markdown or Word, grouped by named colour - Verse of the day push to the ntfy topic (needs IQBIBLE_API_KEY) - Sharing: co-teacher editing via single-use invite links; read-only links can expire and take a passcode Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
88bc59bac0
commit
54cb1c217d
73 files changed
+2739
-134
No files matched your search
@@ -0,0 +1,85 @@
|
||||
// A one-page-per-session handout for a group: the passage, the outline, discussion questions and room to write.
|
||||
// It opens as a print-friendly page ("Save as PDF" in the print dialog).
|
||||
import { formatChunkReference, getChunkVerseEntries } from './bibleUtils.jsx';
|
||||
import { flatten } from './outline.js';
|
||||
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
const lines = (text) => String(text ?? '').split('\n').map((l) => l.replace(/^\s*(?:[-*•–]|\d{1,2}[.)])\s+/, '').trim()).filter(Boolean);
|
||||
|
||||
// Every session (chunk) of the study, in order, with where it sits.
|
||||
export function sessionsFor(project) {
|
||||
const out = [];
|
||||
(project?.chapters ?? []).forEach((chapter, chapterIndex) => {
|
||||
for (const chunk of chapter.chunks ?? []) out.push({ chunk, chapterIndex, number: out.length + 1, reference: formatChunkReference(project, chapterIndex, chunk, '–') });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// The outline points for a session: its own outline, else the study outline's points linked to it.
|
||||
export function outlineForSession(project, chunk) {
|
||||
if ((chunk.outline ?? []).length) return chunk.outline;
|
||||
const linked = (project.outline ?? []).filter((n) => n.chunkId === chunk.id);
|
||||
return linked.length ? linked : [];
|
||||
}
|
||||
|
||||
// The questions to print: the ones written for the session, else its Application notes as "Think about" prompts.
|
||||
export function questionsFor(chunk) {
|
||||
const own = lines(chunk.discussionQuestions);
|
||||
if (own.length) return { kind: 'questions', items: own };
|
||||
const fallback = lines(chunk.application);
|
||||
return fallback.length ? { kind: 'think', items: fallback } : { kind: 'none', items: [] };
|
||||
}
|
||||
|
||||
function sessionHtml(project, session, { answerLines }) {
|
||||
const { chunk, chapterIndex, number, reference } = session;
|
||||
const series = (project.series ?? '').trim();
|
||||
const title = (chunk.episodeTitle ?? '').trim();
|
||||
const num = (chunk.episodeNumber ?? '').toString().trim() || number;
|
||||
const verses = getChunkVerseEntries(project, chapterIndex, chunk);
|
||||
const outline = flatten(outlineForSession(project, chunk));
|
||||
const q = questionsFor(chunk);
|
||||
const blank = (n) => Array.from({ length: n }, () => '<div class="rule"></div>').join('');
|
||||
return `<section class="page">
|
||||
<header>
|
||||
<p class="kicker">${esc(series || project.title)}</p>
|
||||
<h1>Session ${esc(num)}${title ? `: ${esc(title)}` : ''}</h1>
|
||||
<p class="ref">${esc(reference)} <span>(${esc(project.translation ?? 'BSB')})</span></p>
|
||||
<p class="meta">Name: ____________________________ Date: ________________</p>
|
||||
</header>
|
||||
${verses.length ? `<div class="passage">${verses.map((v) => `<p><sup>${esc(v.number)}</sup> ${esc(v.text)}</p>`).join('')}</div>` : ''}
|
||||
${outline.length ? `<h2>Outline</h2><ul class="outline">${outline.map(({ node, depth, label }) => `<li style="margin-left:${depth * 1.2}em"><b>${esc(label)}</b> ${esc(node.text || '')}${node.ref ? ` <i>(${esc(node.ref)})</i>` : ''}</li>`).join('')}</ul>` : ''}
|
||||
${q.items.length ? `<h2>${q.kind === 'questions' ? 'Discussion questions' : 'Think about it'}</h2><ol class="questions">${q.items.map((item) => `<li>${esc(item)}${answerLines ? blank(2) : ''}</li>`).join('')}</ol>` : ''}
|
||||
${answerLines ? `<h2>Notes</h2>${blank(q.items.length ? 4 : 10)}` : ''}
|
||||
<footer>${esc(project.title)}</footer>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
// ids: the chunk ids to include, or null for every session
|
||||
export function buildHandoutHtml(project, ids = null, { answerLines = true } = {}) {
|
||||
const sessions = sessionsFor(project).filter((s) => !ids || ids.includes(s.chunk.id));
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><title>${esc(project.title)} — handout</title><style>
|
||||
@page { margin: 18mm 16mm; }
|
||||
body { font-family: Georgia, 'Times New Roman', serif; color: #1f1b16; line-height: 1.5; margin: 0; font-size: 12.5pt; }
|
||||
.page { page-break-after: always; min-height: 95vh; position: relative; }
|
||||
.page:last-child { page-break-after: auto; }
|
||||
.kicker { margin: 0; font: 600 9.5pt -apple-system, Helvetica, Arial, sans-serif; letter-spacing: .14em; text-transform: uppercase; color: #8a4a2a; }
|
||||
h1 { margin: .15em 0 0; font-size: 22pt; font-weight: 600; }
|
||||
.ref { margin: .2em 0 .4em; font-size: 13pt; color: #8a4a2a; font-weight: 600; } .ref span { color: #6b6458; font-weight: 400; font-size: 10pt; }
|
||||
.meta { color: #6b6458; font: 10pt -apple-system, Helvetica, Arial, sans-serif; margin: .2em 0 1em; }
|
||||
h2 { font: 600 10pt -apple-system, Helvetica, Arial, sans-serif; letter-spacing: .12em; text-transform: uppercase; color: #6b6458; border-bottom: 1px solid #d8cfbb; padding-bottom: 3px; margin: 1.3em 0 .5em; }
|
||||
.passage { background: #f6f1e6; border-left: 3px solid #a84e27; padding: .4em 1em; } .passage p { margin: .35em 0; } sup { color: #a84e27; font-size: 70%; font-family: -apple-system, Helvetica, Arial, sans-serif; }
|
||||
.outline { list-style: none; padding: 0; margin: 0; } .outline li { margin-bottom: .25em; } .outline b { color: #a84e27; }
|
||||
.questions { padding-left: 1.4em; } .questions li { margin-bottom: .7em; }
|
||||
.rule { border-bottom: 1px solid #c9bfa8; height: 1.7em; }
|
||||
footer { position: absolute; bottom: 0; left: 0; right: 0; text-align: center; color: #9b9484; font: 9pt -apple-system, Helvetica, Arial, sans-serif; }
|
||||
</style></head><body>${sessions.map((s) => sessionHtml(project, s, { answerLines })).join('')}</body></html>`;
|
||||
}
|
||||
|
||||
export function printHandout(project, ids = null, options) {
|
||||
const win = window.open('', '_blank');
|
||||
if (!win) return false;
|
||||
win.document.write(buildHandoutHtml(project, ids, options));
|
||||
win.document.close();
|
||||
win.onload = () => { win.focus(); win.print(); };
|
||||
return true;
|
||||
}
|
||||
Reference in new issue
Block a user