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:
nmemmertandClaude Sonnet 5.5 committed 2026-10-14 13:08:21 -04:00
1 parent 67645cf1df
commit 2c7c7145c4
14 files changed
+303 -33

No files matched your search

+19 -5
View File
@@ -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>
);
}
+30
View File
@@ -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>
);
}