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,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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user