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