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

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

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

No files matched your search

+33 -2
View File
@@ -1,7 +1,8 @@
// Observe / Interpret / Apply as an accordion (one open at a time), plus a "More"
// area for background notes and tags so the page stays quiet.
import { useEffect, useState } from 'react';
import { templatePrompt } from '../../utils/studyTemplates.js';
import DictateButton, { appendSpoken } from '../DictateButton.jsx';
import { templateFromChunk, templatePrompt } from '../../utils/studyTemplates.js';
const SECTIONS = [
{ field: 'observation', label: 'Observe', hint: 'What does the text say?',
@@ -15,11 +16,13 @@ const SECTIONS = [
const areaCls = 'w-full resize-y rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3.5 py-3 font-serif text-[15px] leading-7 text-[var(--r-text)] placeholder:font-sans placeholder:text-[13px] placeholder:leading-5 placeholder:text-[var(--r-sub)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
const words = (s) => (s ?? '').trim().split(/\s+/).filter(Boolean).length;
export default function NotesPane({ chunk, updateChunk, allTags, templateId }) {
export default function NotesPane({ chunk, updateChunk, allTags, templateId, saveTemplate, notify }) {
const [open, setOpen] = useState(() => {
try { return localStorage.getItem('studyNotesOpen') || 'observation'; } catch { return 'observation'; }
});
const [more, setMore] = useState(false);
const [tplName, setTplName] = useState('');
const [tplOpen, setTplOpen] = useState(false);
const [tagInput, setTagInput] = useState('');
useEffect(() => { setTagInput(''); }, [chunk.id]);
useEffect(() => { try { localStorage.setItem('studyNotesOpen', open || ''); } catch { /* storage unavailable */ } }, [open]);
@@ -53,6 +56,7 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId }) {
<div className="px-4 pb-4">
<textarea aria-label={label} value={chunk[field] ?? ''} rows={7} placeholder={templatePrompt(templateId, field) ?? placeholder}
onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })} className={areaCls} />
<div className="mt-1.5"><DictateButton label={`Dictate ${label.toLowerCase()}`} onText={(said) => updateChunk(chunk.id, { [field]: appendSpoken(chunk[field], said) })} /></div>
</div>
)}
</div>
@@ -71,7 +75,34 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId }) {
Background / general notes
<textarea value={chunk.generalNotes ?? ''} rows={5} onChange={(e) => updateChunk(chunk.id, { generalNotes: e.target.value })}
placeholder="e.g. author, date, audience, historical context, key themes…" className={`mt-1 ${areaCls}`} />
<span className="mt-1 block"><DictateButton label="Dictate background" onText={(said) => updateChunk(chunk.id, { generalNotes: appendSpoken(chunk.generalNotes, said) })} /></span>
</label>
<label className="block text-xs text-[var(--r-sub)]">
Discussion questions <span className="opacity-70">(one per line; printed on the lesson handout)</span>
<textarea value={chunk.discussionQuestions ?? ''} rows={4} onChange={(e) => updateChunk(chunk.id, { discussionQuestions: e.target.value })}
placeholder={'What does this passage say about God?\nWhere do you see this in your own life?'} className={`mt-1 ${areaCls}`} />
</label>
{saveTemplate && (
<div className="rounded-xl border border-dashed border-[var(--r-line)] p-3">
{!tplOpen ? (
<button type="button" onClick={() => setTplOpen(true)} className="text-xs text-[var(--r-accent)] hover:underline">Save this chunk’s notes as a template…</button>
) : (
<div>
<p className="mb-2 text-xs text-[var(--r-sub)]">What’s written in this chunk’s note boxes becomes the starting text of each new chunk in studies made from the template, along with this study’s prompts.</p>
<div className="flex flex-wrap items-center gap-2">
<input type="text" aria-label="Template name" value={tplName} onChange={(e) => setTplName(e.target.value)} maxLength={40} placeholder="Template name"
className="min-w-0 flex-1 rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm" />
<button type="button" onClick={() => {
const made = templateFromChunk({ name: tplName, chunk, template: templateId });
if (!made) { notify?.('Give it a name, and write something in a note box first.', 2600); return; }
saveTemplate(made); setTplName(''); setTplOpen(false); notify?.(`Saved the template “${made.name}”. Pick it when you start a new study.`, 3200);
}} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5">Save template</button>
<button type="button" onClick={() => setTplOpen(false)} className="text-xs text-[var(--r-sub)] hover:underline">Cancel</button>
</div>
</div>
)}
</div>
)}
<div>
<p className="mb-1.5 text-xs text-[var(--r-sub)]">Tags</p>
<div className="flex flex-wrap items-center gap-1.5">