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

+26 -2
View File
@@ -5,6 +5,7 @@ import { AppContext, useApp } from './context/AppContext.js';
import { ToastProvider, useToast } from './components/Toasts.jsx';
import CommandPalette from './components/CommandPalette.jsx';
import ShortcutsHelp from './components/ShortcutsHelp.jsx';
import SharedPasscodeForm from './components/SharedPasscodeForm.jsx';
import SharedOutlineView from './components/SharedOutlineView.jsx';
import Tooltips from './components/Tooltips.jsx';
import { useAudio } from './hooks/useAudio.js';
@@ -12,11 +13,12 @@ import { useAuth } from './hooks/useAuth.js';
import { useBibleReader } from './hooks/useBibleReader.js';
import { useProjectLibrary } from './hooks/useProjectLibrary.js';
import { useHistoryNav } from './hooks/useHistoryNav.js';
import { useReadingHistory } from './hooks/useReadingHistory.js';
import { useReadingPlans } from './hooks/useReadingPlans.js';
import { useSharedView } from './hooks/useSharedView.js';
import HomePage from './pages/HomePage.jsx';
import { themeVars } from './components/reader/readerThemes.js';
import { loadRemoteProject, saveRemoteProject } from './syncService.js';
import { joinStudy, loadRemoteProject, saveRemoteProject } from './syncService.js';
import { getSyncBase, setSyncBase } from './utils/syncBase.js';
import {
bookOptions, buildExportHtml, loadProjectIndex, makeId, parseBibleChapter, switchStorageUser,
@@ -73,6 +75,7 @@ const AppInner = () => {
const [readerPlan, setReaderPlan] = useState(null);
const readerPlanProgress = readerPlan ? readingPlans.planProgress.find((p) => p.id === readerPlan.progressId) ?? null : null;
const reader = useBibleReader(currentPage, readerPlanProgress?.translation ?? null);
const readingHistory = useReadingHistory({ active: currentPage === 'reader', book: reader.readerBookAbbrev, chapter: reader.readerChapter });
const audio = useAudio(reader.readerBookAbbrev, reader.readerChapter, reader.readerTranslation);
useEffect(() => { if (currentPage !== 'reader') setReaderPlan(null); }, [currentPage]);
// A new page starts at the top. Without this the scroll position of the previous page carries over, and on
@@ -99,11 +102,14 @@ const AppInner = () => {
reader.pullServerMarks();
// Theme, default translation, layout, commentary and reading position.
reader.pullServerPrefs();
// What you've read, merged with this device's.
readingHistory.pullReadingHistory();
},
onSignOut: () => {
reader.stopMarksSync();
reader.stopPrefsSync();
reader.stopInkSync();
readingHistory.stopReadingHistory();
readingPlans.stopReadingPlansSync();
setProjectIndex(switchStorageUser(null));
goStudies();
@@ -117,6 +123,22 @@ const AppInner = () => {
setCurrentPage,
});
// Opening an invite link (?join=TOKEN) while signed in adds that study to your list, ready to edit.
useEffect(() => {
if (!authUser) return;
const token = new URLSearchParams(window.location.search).get('join');
if (!token) return;
window.history.replaceState({}, '', window.location.pathname);
joinStudy(token).then(async (joined) => {
if (!joined.ok) { toast(joined.error ?? 'Could not use that invite link.', { tone: 'error' }); return; }
const full = await loadRemoteProject(joined.data.projectId);
if (full.ok) { saveProjectToStorage(full.data); setSyncBase(full.data.id, full.data.lastEdited); setProjectIndex(loadProjectIndex()); }
library.refreshLibrary();
toast(`You can now edit “${joined.data.title}”. It's in My Studies.`);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [authUser]);
// ⌘K search/go-to, the "?" shortcut list, and requests other pages make of the Reader.
const [paletteOpen, setPaletteOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
@@ -479,6 +501,7 @@ const AppInner = () => {
...audio,
// Reader
...reader,
readingHistory: readingHistory.readingHistory,
readerPlan, setReaderPlan,
readerPlanActive: !!readerPlanProgress,
openPlanReading,
@@ -523,13 +546,14 @@ function PageLoading() {
function AppRouter() {
const { currentPage, authUser, authServerDown } = useApp();
const { token: sharedViewToken, project: sharedProject, error: sharedError } = useSharedView();
const { token: sharedViewToken, project: sharedProject, error: sharedError, needsPasscode, passcodeError, checking, submitPasscode } = useSharedView();
const sharedOutlineToken = new URLSearchParams(window.location.search).get('outline');
// A read-only outline link: no sign-in, nothing editable
if (sharedOutlineToken) return <SharedOutlineView token={sharedOutlineToken} />;
if (sharedViewToken) {
if (needsPasscode) return <SharedPasscodeForm dark error={passcodeError} busy={checking} onSubmit={submitPasscode} />;
if (sharedError) {
return (
<div className="flex min-h-dvh items-center justify-center bg-slate-900 px-4">