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

+15 -2
View File
@@ -165,6 +165,16 @@ export function useBibleReader(currentPage, translationOverride = null) {
return next;
});
};
// Study templates you saved from your own notes; they follow the account like the other preferences.
const [userTemplates, setUserTemplatesState] = useState(() => {
try { const v = JSON.parse(localStorage.getItem('study-templates') || '[]'); return Array.isArray(v) ? v : []; } catch { return []; }
});
const setUserTemplates = (next) => {
setUserTemplatesState(next);
try { localStorage.setItem('study-templates', JSON.stringify(next)); } catch {}
};
const saveUserTemplate = (template) => setUserTemplates([...userTemplates, template].slice(-12));
const deleteUserTemplate = (id) => setUserTemplates(userTemplates.filter((t) => t.id !== id));
// Debounce timer ref so rapid stroke updates don't flood the server
const inkSaveTimerRef = useRef({});
@@ -523,7 +533,8 @@ export function useBibleReader(currentPage, translationOverride = null) {
// Signing out clears this account's ink from the device.
const stopInkSync = useCallback(() => {
Object.keys(inkSaveTimerRef.current).forEach((k) => clearTimeout(inkSaveTimerRef.current[k]));
['readerInkByPage', 'readerInkDirty'].forEach((k) => { try { localStorage.removeItem(k); } catch {} });
['readerInkByPage', 'readerInkDirty', 'study-templates'].forEach((k) => { try { localStorage.removeItem(k); } catch {} });
setUserTemplatesState([]);
setReaderInkByPage({});
}, []);
@@ -539,7 +550,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
theme: readerTheme, translation: baseTranslation, layout: readerLayout, commentarySource: readerCommentary,
planTranslation: planTranslationPref, startPage: startPagePref,
position: { book: readerBookAbbrev, chapter: readerChapter },
colorLabels: readerColorLabels,
colorLabels: readerColorLabels, studyTemplates: userTemplates,
},
apply: (key, v) => {
if (key === 'theme') { setReaderThemeState(v); try { localStorage.setItem('reader-theme', v); } catch {} }
@@ -549,6 +560,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
else if (key === 'planTranslation') { setPlanTranslationPrefState(v); try { localStorage.setItem('plan-translation', v); } catch {} }
else if (key === 'commentarySource') { setReaderCommentaryState(v); try { localStorage.setItem('reader-commentary', v); } catch {} }
else if (key === 'colorLabels') { setReaderColorLabelsState(v); try { localStorage.setItem('reader-color-labels', JSON.stringify(v)); } catch {} }
else if (key === 'studyTemplates') { setUserTemplatesState(v); try { localStorage.setItem('study-templates', JSON.stringify(v)); } catch {} }
else if (key === 'position') { setReaderBookAbbrev(v.book); setReaderChapter(v.chapter); setReaderSelectedVerse(null); }
},
canApplyPosition: () => pageRef.current !== 'reader',
@@ -597,6 +609,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
removeTextHighlight,
restoreTextHighlight,
readerColorLabels, setReaderColorLabel,
userTemplates, saveUserTemplate, deleteUserTemplate,
bibleIndexStatusFor,
loadBibleIndex,
_bibleIndexCacheRef,
+11 -6
View File
@@ -102,7 +102,7 @@ function initialChapterIndex(project) {
return index >= 0 ? index : 0;
}
export function useChunkBuilder({ project, setProject, updateProject, allChunks, setCurrentPage }) {
export function useChunkBuilder({ project, setProject, updateProject, allChunks, setCurrentPage, userTemplates = [] }) {
const { toast } = useToast();
const [setup, setSetup] = useState(() => initialSetupForm(project));
const [titleEdited, setTitleEdited] = useState(false);
@@ -138,7 +138,10 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
const activeChapter = project?.chapters[activeChapterIndex] ?? null;
const handleSetupField = (field, value) => {
if (field === 'book') {
if (field === 'template') {
// a template of your own is copied into the study, so it keeps working wherever the study goes
setSetup((current) => ({ ...current, template: value, templateDef: userTemplates.find((t) => t.id === value) }));
} else if (field === 'book') {
const selected = bookOptions.find((book) => book.abbrev === value) ?? bookOptions[0];
setSetup((current) => ({
...current,
@@ -195,6 +198,8 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
title: setup.title,
translation: setup.translation,
templateId: setup.template ?? 'blank',
...(setup.templateDef ? { templateDef: setup.templateDef } : {}), // a copy of your own template
lastEdited: Date.now(),
selectedChunkId: null,
chapters: [
@@ -237,7 +242,7 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
&& (c.spilloverEndVerse ?? null) === (spilloverEndVerse ?? null),
);
if (overlap) return ch;
const newChunk = blankChunk(start, end, spilloverEndVerse, current.templateId);
const newChunk = blankChunk(start, end, spilloverEndVerse, current.templateDef ?? current.templateId);
return { ...ch, chunks: [...ch.chunks, newChunk] };
});
// Find the newly added chunk id
@@ -286,7 +291,7 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
skippedCount += 1;
return;
}
const newChunk = blankChunk(start, end, spilloverEndVerse, current.templateId);
const newChunk = blankChunk(start, end, spilloverEndVerse, current.templateDef ?? current.templateId);
existing.add(key);
newChunks.push(newChunk);
addedCount += 1;
@@ -577,12 +582,12 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
if (i >= 0) {
const c = chunks[i];
chunks[i] = { ...c, endVerse: n, spilloverEndVerse: null };
chunks.splice(i + 1, 0, blankChunk(n + 1, c.endVerse, c.spilloverEndVerse ?? null, current.templateId));
chunks.splice(i + 1, 0, blankChunk(n + 1, c.endVerse, c.spilloverEndVerse ?? null, current.templateDef ?? current.templateId));
return { ...ch, chunks };
}
if (chunks.some((c) => c.startVerse <= n && n <= c.endVerse)) return ch; // already a boundary
const start = Math.max(0, ...chunks.filter((c) => c.endVerse < n).map((c) => c.endVerse)) + 1;
const created = blankChunk(start, n, null, current.templateId);
const created = blankChunk(start, n, null, current.templateDef ?? current.templateId);
selected = created.id;
const at = chunks.findIndex((c) => c.startVerse > n);
chunks.splice(at < 0 ? chunks.length : at, 0, created);
+9 -6
View File
@@ -5,6 +5,8 @@ import { disableOutlineSharing, enableOutlineSharing, getOutlineShareStatus } fr
export function useOutlineShareLink(projectId, enabled = true) {
const [token, setToken] = useState(null);
const [expiresAt, setExpiresAt] = useState(null);
const [hasPasscode, setHasPasscode] = useState(false);
const [state, setState] = useState('loading'); // 'loading' | 'ready' | 'unavailable' (no account / server)
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
@@ -17,19 +19,20 @@ export function useOutlineShareLink(projectId, enabled = true) {
setState('loading');
getOutlineShareStatus(projectId).then((result) => {
if (!active) return;
if (result.ok) { setToken(result.data.shareToken); setState('ready'); } else setState('unavailable');
if (result.ok) { setToken(result.data.shareToken); setExpiresAt(result.data.expiresAt ?? null); setHasPasscode(!!result.data.hasPasscode); setState('ready'); } else setState('unavailable');
});
return () => { active = false; };
}, [projectId, enabled]);
const url = token ? `${window.location.origin}${window.location.pathname}?outline=${token}` : '';
const create = async () => {
// Creates the link, or changes its rules: { expiresInDays?: number | null, passcode?: string | null }
const create = async (options = {}) => {
setBusy(true);
setError('');
const result = await enableOutlineSharing(projectId);
const result = await enableOutlineSharing(projectId, options);
setBusy(false);
if (result.ok) setToken(result.data.shareToken);
if (result.ok) { setToken(result.data.shareToken); setExpiresAt(result.data.expiresAt ?? null); setHasPasscode(!!result.data.hasPasscode); }
else setError(result.status === 404 ? 'Your study hasn’t synced to your account yet. Wait a moment and try again.' : 'Couldn’t create the link. Check your connection and try again.');
};
@@ -37,8 +40,8 @@ export function useOutlineShareLink(projectId, enabled = true) {
setBusy(true);
const result = await disableOutlineSharing(projectId);
setBusy(false);
if (result.ok) setToken(null); else setError('Couldn’t turn the link off. Try again.');
if (result.ok) { setToken(null); setExpiresAt(null); setHasPasscode(false); } else setError('Couldn’t turn the link off. Try again.');
};
return { token, url, state, busy, error, create, turnOff };
return { token, url, expiresAt, hasPasscode, state, busy, error, create, turnOff };
}
+2 -2
View File
@@ -5,7 +5,7 @@
import { useCallback, useEffect, useRef } from 'react';
import { getRemotePrefs, saveRemotePrefs } from '../syncService.js';
export const PREF_KEYS = ['theme', 'translation', 'layout', 'commentarySource', 'planTranslation', 'startPage', 'position', 'colorLabels'];
export const PREF_KEYS = ['theme', 'translation', 'layout', 'commentarySource', 'planTranslation', 'startPage', 'position', 'colorLabels', 'studyTemplates'];
const META_KEY = 'prefs-meta'; // { key: timestamp of the last change made on this device }
const PUSH_DELAY_MS = 1000;
const FOCUS_PULL_MIN_MS = 30_000;
@@ -58,7 +58,7 @@ export function usePrefsSync({ values, apply, canApplyPosition = () => true }) {
window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(push, PUSH_DELAY_MS);
}
}, [values.theme, values.translation, values.layout, values.commentarySource, values.planTranslation, values.startPage, values.position.book, values.position.chapter, values.colorLabels, push]); // eslint-disable-line react-hooks/exhaustive-deps
}, [values.theme, values.translation, values.layout, values.commentarySource, values.planTranslation, values.startPage, values.position.book, values.position.chapter, values.colorLabels, values.studyTemplates, push]); // eslint-disable-line react-hooks/exhaustive-deps
const pullServerPrefs = useCallback(async () => {
enabledRef.current = true;
+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,
};
+72
View File
@@ -0,0 +1,72 @@
// Remembers which chapters you have read in the Reader, and keeps that in step with your account.
// A chapter counts as read after you have had it open for a little while (not just flicked past).
// It is saved on this device, and merged with the account's copy (never overwriting it) when you sign in
// and a moment after each new chapter, so two devices both add to the same history.
import { useCallback, useEffect, useRef, useState } from 'react';
import { getRemoteHistory, saveRemoteHistory } from '../syncService.js';
import { emptyHistory, historyEqual, mergeHistory, normalizeHistory, recordRead } from '../utils/readingHistory.js';
import { localDateString } from '../utils/readingPlans.js';
const KEY = 'reading-history';
export const DWELL_MS = 15_000;
const PUSH_DELAY_MS = 3000;
const load = () => { try { return normalizeHistory(JSON.parse(localStorage.getItem(KEY) || 'null')); } catch { return emptyHistory(); } };
const save = (h) => { try { localStorage.setItem(KEY, JSON.stringify(h)); } catch { /* storage unavailable */ } };
export function useReadingHistory({ active, book, chapter }) {
const [history, setHistory] = useState(load);
const historyRef = useRef(history);
historyRef.current = history;
const enabledRef = useRef(false);
const timerRef = useRef(null);
const apply = useCallback((next) => {
if (historyEqual(next, historyRef.current)) return;
historyRef.current = next;
save(next);
setHistory(next);
}, []);
const push = useCallback(async () => {
window.clearTimeout(timerRef.current);
if (!enabledRef.current) return;
const result = await saveRemoteHistory(historyRef.current);
if (result.ok) apply(mergeHistory(historyRef.current, result.data));
}, [apply]);
// A chapter on screen long enough counts as read.
useEffect(() => {
if (!active || !book || !chapter) return undefined;
const t = window.setTimeout(() => {
if (document.visibilityState === 'hidden') return;
const next = recordRead(historyRef.current, book, chapter, localDateString());
if (next === historyRef.current) return;
apply(next);
if (enabledRef.current) { window.clearTimeout(timerRef.current); timerRef.current = window.setTimeout(push, PUSH_DELAY_MS); }
}, DWELL_MS);
return () => window.clearTimeout(t);
}, [active, book, chapter, apply, push]);
// Signing in: combine this device's history with the account's, and send back anything the account lacks.
const pull = useCallback(async () => {
enabledRef.current = true;
const result = await getRemoteHistory();
if (!result.ok) return;
const merged = mergeHistory(historyRef.current, result.data);
apply(merged);
if (!historyEqual(merged, result.data)) await push();
}, [apply, push]);
// Signing out clears this device's copy, so the next person starts clean.
const stop = useCallback(() => {
enabledRef.current = false;
window.clearTimeout(timerRef.current);
try { localStorage.removeItem(KEY); } catch { /* storage unavailable */ }
historyRef.current = emptyHistory();
setHistory(emptyHistory());
}, []);
useEffect(() => () => window.clearTimeout(timerRef.current), []);
return { readingHistory: history, pullReadingHistory: pull, stopReadingHistory: stop };
}
+56
View File
@@ -0,0 +1,56 @@
import { renderHook, act } from '@testing-library/react';
import { vi, test, expect, beforeEach, afterEach } from 'vitest';
vi.mock('../syncService.js', () => ({ getRemoteHistory: vi.fn(), saveRemoteHistory: vi.fn() }));
import { useReadingHistory, DWELL_MS } from './useReadingHistory.js';
import { getRemoteHistory, saveRemoteHistory } from '../syncService.js';
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
vi.useFakeTimers();
getRemoteHistory.mockResolvedValue({ ok: true, data: { chapters: { 'ROM.8': { n: 1, last: '2026-09-01' } }, days: ['2026-09-01'] } });
saveRemoteHistory.mockImplementation(async (h) => ({ ok: true, data: h }));
});
afterEach(() => vi.useRealTimers());
const setup = (props) => renderHook((p) => useReadingHistory(p), { initialProps: { active: true, book: 'JHN', chapter: 3, ...props } });
test('a chapter counts as read only after it has been open a while', () => {
const { result } = setup();
act(() => { vi.advanceTimersByTime(DWELL_MS - 1000); });
expect(Object.keys(result.current.readingHistory.chapters)).toEqual([]);
act(() => { vi.advanceTimersByTime(1500); });
expect(Object.keys(result.current.readingHistory.chapters)).toEqual(['JHN.3']);
expect(JSON.parse(localStorage.getItem('reading-history')).chapters['JHN.3'].n).toBe(1);
});
test('flicking past a chapter, or not being in the Reader, counts for nothing', () => {
const { result, rerender } = setup();
act(() => { vi.advanceTimersByTime(5000); });
rerender({ active: true, book: 'JHN', chapter: 4 }); // moved on after 5 seconds
act(() => { vi.advanceTimersByTime(DWELL_MS - 1000); });
expect(result.current.readingHistory.chapters).toEqual({});
rerender({ active: false, book: 'JHN', chapter: 4 });
act(() => { vi.advanceTimersByTime(DWELL_MS * 2); });
expect(result.current.readingHistory.chapters).toEqual({});
});
test('signing in merges with the account\'s history and sends back what it lacked', async () => {
const { result } = setup({ active: false });
localStorage.setItem('reading-history', JSON.stringify({ chapters: { 'GEN.1': { n: 1, last: '2026-10-01' } }, days: ['2026-10-01'] }));
const fresh = renderHook(() => useReadingHistory({ active: false, book: 'GEN', chapter: 1 }));
await act(async () => { await fresh.result.current.pullReadingHistory(); });
expect(Object.keys(fresh.result.current.readingHistory.chapters).sort()).toEqual(['GEN.1', 'ROM.8']);
expect(saveRemoteHistory).toHaveBeenCalled(); // the account didn't have GEN.1
expect(result.current.readingHistory).toBeTruthy();
});
test('signing out clears this device\'s history', async () => {
const { result } = setup();
act(() => { vi.advanceTimersByTime(DWELL_MS + 100); });
expect(Object.keys(result.current.readingHistory.chapters)).toHaveLength(1);
act(() => result.current.stopReadingHistory());
expect(result.current.readingHistory).toEqual({ chapters: {}, days: [] });
expect(localStorage.getItem('reading-history')).toBeNull();
});
+17 -9
View File
@@ -1,18 +1,23 @@
// Read-only share link for the open project: status, enable, revoke, copy.
// Read-only share link for the open project: status, enable, change its rules (expiry, passcode), revoke, copy.
import { useEffect, useState } from 'react';
import { disableSharing, enableSharing, getShareStatus } from '../syncService.js';
export function useShareLink(projectId) {
const [shareToken, setShareToken] = useState(null);
const [expiresAt, setExpiresAt] = useState(null);
const [hasPasscode, setHasPasscode] = useState(false);
const [shareBusy, setShareBusy] = useState(false);
const [shareCopied, setShareCopied] = useState(false);
const [shareError, setShareError] = useState('');
const apply = (data) => { setShareToken(data.shareToken ?? null); setExpiresAt(data.expiresAt ?? null); setHasPasscode(!!data.hasPasscode); };
useEffect(() => {
setShareToken(null);
if (!projectId) return;
setShareToken(null); setExpiresAt(null); setHasPasscode(false); setShareError('');
if (!projectId) return undefined;
let active = true;
getShareStatus(projectId).then((result) => {
if (active && result.ok) setShareToken(result.data.shareToken);
if (active && result.ok) apply(result.data);
});
return () => { active = false; };
}, [projectId]);
@@ -21,11 +26,14 @@ export function useShareLink(projectId) {
? `${window.location.origin}${window.location.pathname}?share=${shareToken}`
: '';
const enable = async () => {
// Turns the link on, or changes its rules: { expiresInDays?: number | null, passcode?: string | null }
const enable = async (options = {}) => {
setShareBusy(true);
const result = await enableSharing(projectId);
setShareError('');
const result = await enableSharing(projectId, options);
setShareBusy(false);
if (result.ok) setShareToken(result.data.shareToken);
if (result.ok) apply(result.data); else setShareError(result.error ?? 'Could not save the link settings.');
return result.ok;
};
const revoke = async () => {
@@ -33,7 +41,7 @@ export function useShareLink(projectId) {
setShareBusy(true);
const result = await disableSharing(projectId);
setShareBusy(false);
if (result.ok) setShareToken(null);
if (result.ok) apply({});
};
const copy = () => {
@@ -44,5 +52,5 @@ export function useShareLink(projectId) {
});
};
return { shareToken, shareUrl, shareBusy, shareCopied, enable, revoke, copy };
return { shareToken, shareUrl, expiresAt, hasPasscode, shareBusy, shareCopied, shareError, enable, revoke, copy };
}
+16 -8
View File
@@ -1,20 +1,28 @@
// A ?share=TOKEN URL shows a read-only view of someone else's project. No
// login is needed, so this runs independently of auth.
import { useEffect, useState } from 'react';
// login is needed, so this runs independently of auth. A link may need a passcode.
import { useCallback, useEffect, useState } from 'react';
import { getSharedProject } from '../syncService.js';
export function useSharedView() {
const [token] = useState(() => new URLSearchParams(window.location.search).get('share'));
const [project, setProject] = useState(null);
const [error, setError] = useState('');
const [needsPasscode, setNeedsPasscode] = useState(false);
const [passcodeError, setPasscodeError] = useState('');
const [checking, setChecking] = useState(false);
useEffect(() => {
if (!token) return;
getSharedProject(token).then((result) => {
if (result.ok) setProject(result.data);
else setError(result.error ?? 'This share link is invalid or has been revoked.');
const load = useCallback((passcode) => {
setChecking(true);
return getSharedProject(token, passcode).then((result) => {
setChecking(false);
if (result.ok) { setProject(result.data); setNeedsPasscode(false); setPasscodeError(''); return; }
if (result.data?.needsPasscode) { setNeedsPasscode(true); setPasscodeError(passcode ? result.error : ''); return; }
setNeedsPasscode(false);
setError(result.error ?? 'This share link is invalid or has been revoked.');
});
}, [token]);
return { token, project, error };
useEffect(() => { if (token) load(); }, [token, load]);
return { token, project, error, needsPasscode, passcodeError, checking, submitPasscode: load };
}