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

+137
View File
@@ -0,0 +1,137 @@
// 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.
import { useCallback, useEffect, useRef, useState } from 'react';
import { formatClock, outlineSteps, paceText, pacing } from '../utils/present.js';
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';
export default function PresentMode({ title, subtitle = '', mode, script = '', tree = [], onClose }) {
const [dark, setDark] = useState(true);
const [size, setSize] = useState(() => { try { return Math.max(0, SIZES.indexOf(Number(localStorage.getItem('present-size')) || 34)); } catch { return 3; } });
const [elapsed, setElapsed] = useState(0);
const [running, setRunning] = useState(false);
const [scrolling, setScrolling] = useState(false);
const [speed, setSpeed] = useState(30); // pixels per second
const [index, setIndex] = useState(0);
const scroller = useRef(null);
const steps = mode === 'outline' ? outlineSteps(tree) : [];
const step = steps[index];
const pace = mode === 'outline' ? pacing(steps, index, elapsed) : null;
// the clock
useEffect(() => {
if (!running) return undefined;
const t = window.setInterval(() => setElapsed((e) => e + 1), 1000);
return () => window.clearInterval(t);
}, [running]);
// the teleprompter
useEffect(() => {
if (!scrolling || mode !== 'script') return undefined;
let last = performance.now();
let frame;
let carry = 0;
const tick = (now) => {
const el = scroller.current;
carry += ((now - last) / 1000) * speed;
last = now;
if (el && carry >= 1) { el.scrollTop += Math.floor(carry); carry -= Math.floor(carry); }
if (el && el.scrollTop + el.clientHeight >= el.scrollHeight - 2) { setScrolling(false); return; }
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [scrolling, speed, mode]);
// keep the screen on
useEffect(() => {
let lock;
let gone = false;
navigator.wakeLock?.request?.('screen').then((l) => { if (gone) l.release?.(); else lock = l; }).catch(() => {});
return () => { gone = true; lock?.release?.().catch?.(() => {}); };
}, []);
const bigger = (d) => setSize((s) => {
const next = Math.min(SIZES.length - 1, Math.max(0, s + d));
try { localStorage.setItem('present-size', String(SIZES[next])); } catch { /* storage unavailable */ }
return next;
});
const go = useCallback((d) => setIndex((i) => Math.min(steps.length - 1, Math.max(0, i + d))), [steps.length]);
useEffect(() => {
const onKey = (e) => {
if (e.key === 'Escape') { onClose(); return; }
if (e.target?.tagName === 'INPUT') return;
if (mode === 'outline') {
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 === '+' || e.key === '=') bigger(1);
if (e.key === '-') bigger(-1);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [mode, go, onClose]);
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;
return (
<div role="dialog" aria-modal="true" aria-label={`Presenting ${title}`} className={`fixed inset-0 z-[80] flex flex-col ${colors}`}>
<div className="flex shrink-0 flex-wrap items-center gap-x-4 gap-y-2 border-b border-current/15 px-4 py-2.5">
<div className="min-w-0 flex-1"><p className="truncate text-sm font-medium">{title}</p>{subtitle && <p className="truncate text-xs opacity-60">{subtitle}</p>}</div>
<div className="flex items-center gap-2" role="group" aria-label="Timer">
<span className="min-w-[4.5rem] text-center font-mono text-xl tabular-nums" aria-live="off">{formatClock(elapsed)}</span>
<button type="button" onClick={() => setRunning((r) => !r)} className={btn}>{running ? 'Pause' : elapsed ? 'Resume' : 'Start'}</button>
<button type="button" onClick={() => { setElapsed(0); setRunning(false); }} disabled={!elapsed} className={btn}>Reset</button>
</div>
{pace && <span className={`text-sm ${pace.diffSeconds < -20 ? 'text-amber-400' : 'opacity-80'}`} role="status">{paceText(pace)} · plan {formatClock(pace.totalSeconds)}</span>}
<div className="flex items-center gap-1.5" role="group" aria-label="Text size">
<button type="button" onClick={() => bigger(-1)} disabled={size === 0} aria-label="Smaller text" className={btn}>A−</button>
<button type="button" onClick={() => bigger(1)} disabled={size === SIZES.length - 1} aria-label="Larger text" className={btn}>A+</button>
<button type="button" onClick={() => setDark((d) => !d)} aria-pressed={dark} className={btn}>{dark ? 'Light' : 'Dark'}</button>
</div>
{mode === 'script' && (
<div className="flex items-center gap-2" role="group" aria-label="Auto-scroll">
<button type="button" onClick={() => setScrolling((s) => !s)} aria-pressed={scrolling} className={btn}>{scrolling ? 'Stop scrolling' : 'Auto-scroll'}</button>
<label className="flex items-center gap-1.5 text-xs opacity-80">Speed
<input type="range" min="8" max="120" value={speed} onChange={(e) => setSpeed(Number(e.target.value))} aria-label="Scroll speed" />
</label>
</div>
)}
<button type="button" onClick={onClose} className={`${btn} font-medium`}>Close</button>
</div>
{mode === 'script' ? (
<div ref={scroller} className="min-h-0 flex-1 overflow-y-auto px-[6vw] py-[8vh]" style={{ scrollBehavior: 'auto' }}>
{script.trim()
? <div className="mx-auto max-w-[46em] whitespace-pre-wrap font-serif" style={{ fontSize, lineHeight: 1.5 }}>{script}<div style={{ height: '50vh' }} aria-hidden="true" /></div>
: <p className="mt-20 text-center text-lg opacity-70">There’s no script to present yet. Write one on the Script page.</p>}
</div>
) : (
<div className="flex min-h-0 flex-1 flex-col">
<div className="min-h-0 flex-1 overflow-y-auto px-[6vw] py-[6vh]">
{step ? (
<div className="mx-auto max-w-[46em] font-serif" style={{ fontSize, lineHeight: 1.4 }}>
<p className="font-sans text-[0.4em] uppercase tracking-[0.16em] opacity-60">{step.kind ? `${step.kind} · ` : ''}Point {index + 1} of {steps.length}{step.minutes ? ` · ${step.minutes} min` : ''}</p>
<h1 className="mt-2 font-semibold"><span className="mr-3 opacity-60">{step.label}</span>{step.text || '(untitled)'}</h1>
{step.ref && <p className="mt-2 text-[0.6em] opacity-70">{step.ref}</p>}
{step.scripture?.text && <blockquote className="mt-5 border-l-4 border-current/30 pl-5 text-[0.8em] italic opacity-90">“{step.scripture.text}” <span className="not-italic opacity-60">— {step.scripture.ref}</span></blockquote>}
{step.note && <p className="mt-5 whitespace-pre-wrap text-[0.7em] opacity-85">{step.note}</p>}
{step.attachments.length > 0 && <p className="mt-5 text-[0.55em] opacity-60">{step.attachments.map((a) => `${a.type === 'word' ? 'Word' : 'See'}: ${a.label}`).join(' · ')}</p>}
</div>
) : <p className="mt-20 text-center text-lg opacity-70">There’s no outline to present yet. Build one on the Outline page.</p>}
</div>
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-current/15 px-4 py-3">
<button type="button" onClick={() => go(-1)} disabled={index === 0} className={btn}>← Back</button>
<span className="text-xs opacity-60">{total ? `${total} min planned` : 'Set minutes on points to see your pace'} · arrows or space to move</span>
<button type="button" onClick={() => go(1)} disabled={index >= steps.length - 1} className={btn}>Next →</button>
</div>
</div>
)}
</div>
);
}