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