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

+45
View File
@@ -0,0 +1,45 @@
// The rules for a read-only link: when it stops working, and an optional passcode. Shown under the link.
import { useState } from 'react';
const OPTIONS = [['', 'Never'], ['1', 'In 1 day'], ['7', 'In 7 days'], ['30', 'In 30 days']];
const field = 'rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
export const expiryText = (expiresAt, now = Date.now()) => {
if (!expiresAt) return 'Never expires';
if (expiresAt <= now) return 'Expired';
const days = Math.ceil((expiresAt - now) / 86_400_000);
return `Expires ${new Date(expiresAt).toLocaleDateString()} (${days} day${days === 1 ? '' : 's'} left)`;
};
export default function LinkRules({ expiresAt, hasPasscode, busy, onSave }) {
const [days, setDays] = useState('keep'); // 'keep' | '' | '1' | '7' | '30'
const [passcode, setPasscode] = useState('');
const dirty = days !== 'keep' || passcode.trim() !== '';
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(''); }
};
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>
<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`}>
<option value="keep">{expiresAt ? 'Keep as is' : 'Never (as is)'}</option>
{OPTIONS.map(([v, label]) => <option key={v || 'never'} value={v}>{label}</option>)}
</select>
</label>
<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>
<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>
</div>
);
}