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

+56
View File
@@ -29,6 +29,9 @@ export function useProjectLibrary({
const pendingDeletes = useRef(new Map()); // id -> timer
const [staleLocalProjects, setStaleLocalProjects] = useState([]); // projects where server is newer
const [autoRestoredCount, setAutoRestoredCount] = useState(null); // brief "synced from another device" toast
// Studies another account invited you to edit: id -> the owner's email (shown on the card)
const [sharedStudies, setSharedStudies] = useState({});
const sharedFrom = (list) => Object.fromEntries(list.filter((e) => e.shared).map((e) => [e.id, e.ownerEmail ?? '']));
// Studies saved here that the server hasn't accepted yet (saved offline, or the tab closed mid-sync).
// A study another device changed meanwhile is left alone; opening it offers the keep-mine/keep-theirs choice.
@@ -76,6 +79,7 @@ export function useProjectLibrary({
const gone = new Set(pendingDeleteIds.list());
await retryPendingDeletes();
const serverList = result.data.filter((e) => !gone.has(e.id));
setSharedStudies(sharedFrom(serverList));
const localMap = new Map(localIndex.map((e) => [e.id, e]));
// What this device has seen of each study on the server: a save made on top of anything older is
// checked against it, so a newer copy from another device is never overwritten silently.
@@ -127,6 +131,29 @@ export function useProjectLibrary({
return () => { cancelled = true; };
}, [authUser]);
// Another device (or a co-teacher) may have saved a study since you opened this page: look again when you come
// back to the tab, and every couple of minutes while it's open. A newer copy is offered, never forced on you.
const refreshStale = async () => {
const result = await listRemoteProjects();
if (!result.ok) return;
const local = new Map(loadProjectIndex().map((e) => [e.id, e]));
const unsynced = new Set(unsyncedStudies.list());
setSharedStudies(sharedFrom(result.data));
setStaleLocalProjects(result.data.filter((e) => {
const mine = local.get(e.id);
return mine && (e.lastEdited ?? 0) > (mine.lastEdited ?? 0) && !unsynced.has(e.id);
}));
};
useEffect(() => {
if (!authUser) return undefined;
let last = Date.now();
const maybe = () => { if (document.visibilityState === 'visible' && Date.now() - last > 30_000) { last = Date.now(); refreshStale(); } };
document.addEventListener('visibilitychange', maybe);
const timer = window.setInterval(maybe, 120_000);
return () => { document.removeEventListener('visibilitychange', maybe); window.clearInterval(timer); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [authUser]);
// Back online: send what the server missed.
useEffect(() => {
const online = () => { if (authUser) { retryPendingDeletes(); pushPending(); } };
@@ -165,6 +192,27 @@ export function useProjectLibrary({
}
};
// Puts a study in a series ("Romans", "Advent 2026"), or takes it out (an empty name).
const setProjectSeries = (id, series) => {
const loaded = loadProjectById(id);
if (!loaded) return;
const name = String(series ?? '').trim().slice(0, 80);
if ((loaded.series ?? '') === name) return;
const updated = { ...loaded, series: name, lastEdited: Date.now() };
saveProjectToStorage(updated);
unsyncedStudies.add(id);
setProjectIndex(loadProjectIndex());
if (project?.id === id) setProject((current) => (current ? { ...current, series: name } : current));
saveRemoteProject(updated, { baseEdited: getSyncBase(id) }).then((result) => {
if (result.ok) {
const serverEdited = result.data?.lastEdited ?? updated.lastEdited;
setSyncBase(id, serverEdited);
unsyncedStudies.remove(id);
try { saveProjectToStorage({ ...updated, lastEdited: serverEdited }); setProjectIndex(loadProjectIndex()); } catch { /* already saved */ }
} else if (result.status === 409) toast('That study changed on another device. Open it to choose which version to keep.', { tone: 'error' });
});
};
// Deleting is immediate here, with an Undo for a few seconds; only after that is the server copy
// removed (and straight away if the tab is closed first, so a deleted project never comes back).
const finishDelete = (id) => {
@@ -190,6 +238,11 @@ export function useProjectLibrary({
pendingDeleteIds.add(id);
setProjectIndex(loadProjectIndex());
pendingDeletes.current.set(id, window.setTimeout(() => finishDelete(id), 8000));
if (sharedStudies[id] !== undefined) {
// A study someone else owns: this only removes you from it (they keep it). A new invite is needed to rejoin.
toast(`You left “${loaded.title}”. Ask for a new invite if you want back in.`);
return;
}
toast(`Deleted “${loaded.title}”`, {
action: {
label: 'Undo',
@@ -218,10 +271,13 @@ export function useProjectLibrary({
};
return {
sharedStudies,
refreshLibrary: refreshStale,
staleLocalProjects,
autoRestoredCount,
resumeProject,
renameProjectInStorage,
setProjectSeries,
deleteProject,
pullLatestFromServer,
};