Add Outline stage, parallel reading, offline saves, backup, sync-conflict handling and more
Study - New Outline stage (hierarchical I./A./1. points, linked to chunks, in all exports); the chunk stage is now "Chunks" - Home cards: passages shown as ranges, tags capped with "+N more" Reader - Side-by-side translations and a Compare tab per verse - Typeface, line spacing, verse-number settings; text and column scale with the screen - Highlights/bookmarks sheet with named colours (synced); BSB footnote spacing fix - Cmd-K palette (passages, studies, notes, marks), keyboard shortcuts and "?" help Flow and safety - Toasts with Undo replace most confirm()/alert() - Back/Forward move between pages - Server refuses saves over a newer copy (409); keep-mine / use-other banner; opening a study no longer re-stamps it - Latest chapter request wins when flipping quickly Offline and data - Save books, plan days or a whole BSB/NET for offline - Service worker fixes: helloao /api/ URLs were skipped and responses cloned too late - Settings: download/restore a full backup Accessibility - Sheets trap focus and close on Escape, skip link, focus ring, reduced motion, theme contrast raised to 4.5:1 (with a test) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1edbd364ea
commit
d13f55faf7
65 files changed
+2947
-208
No files matched your search
+104
-35
@@ -6,21 +6,29 @@ import { bookOptions, CHAPTER_COUNTS, parseBibleChapter } from '../utils/bibleUt
|
||||
import { useReaderMarksSync } from './useReaderMarksSync.js';
|
||||
import { usePrefsSync } from './usePrefsSync.js';
|
||||
import { COMMENTARIES } from './useVerseStudy.js';
|
||||
import { READER_TRANSLATIONS, fetchChapter } from '../utils/bibleText.js';
|
||||
import { mergeMarks, normalizeMarks } from '../utils/readerMarks.js';
|
||||
import { BIBLE_IDB_TTL, getBibleIndexFromIDB, saveBibleIndexToIDB } from '../utils/bibleIndexCache.js';
|
||||
|
||||
export const READER_TRANSLATIONS = [
|
||||
{ id: 'BSB', name: 'Berean Standard Bible' },
|
||||
{ id: 'ESV', name: 'English Standard Version', licensed: true,
|
||||
notice: 'Scripture quotations are from the ESV® Bible (The Holy Bible, English Standard Version®), © 2001 by Crossway, a publishing ministry of Good News Publishers. Used by permission. All rights reserved.' },
|
||||
{ id: 'NET', name: 'NET Bible', helloao: 'eng_net',
|
||||
notice: 'Scripture quoted by permission. Quotations designated (NET) are from the NET Bible® copyright ©1996-2017 by Biblical Studies Press, L.L.C. http://netbible.com All rights reserved.' },
|
||||
{ id: 'NLT', name: 'New Living Translation', licensed: true,
|
||||
notice: 'Holy Bible, New Living Translation, Copyright © 2014, Tyndale House Publishers. All rights reserved. tyndale.com' },
|
||||
{ id: 'CSB', name: 'Christian Standard Bible', licensed: true,
|
||||
notice: 'Christian Standard Bible® and CSB® are federally registered trademarks of Holman Bible Publishers. All rights reserved. bhpublishinggroup.com' },
|
||||
{ id: 'NASB', name: 'New American Standard Bible', licensed: true,
|
||||
notice: 'New American Standard Bible®, Copyright © 1995, The Lockman Foundation. All rights reserved. lockman.org' },
|
||||
];
|
||||
export { READER_TRANSLATIONS };
|
||||
|
||||
// A per-device choice kept in localStorage: useStoredChoice('reader-font', 'serif', ['serif', 'sans']).
|
||||
function useStoredChoice(key, fallback, allowed) {
|
||||
const [value, setValue] = useState(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(key);
|
||||
return saved !== null && allowed.includes(saved) ? saved : fallback;
|
||||
} catch { return fallback; }
|
||||
});
|
||||
const set = (next) => {
|
||||
setValue(next);
|
||||
try { localStorage.setItem(key, next); } catch {}
|
||||
};
|
||||
return [value, set];
|
||||
}
|
||||
|
||||
export const READER_FONTS = ['serif', 'sans', 'easy'];
|
||||
export const READER_SPACINGS = ['snug', 'normal', 'airy'];
|
||||
|
||||
const BOOKMARK_COLORS = ['#fef08a', '#bbf7d0', '#bfdbfe', '#fecaca', '#e9d5ff'];
|
||||
|
||||
@@ -91,6 +99,11 @@ export function useBibleReader(currentPage, translationOverride = null) {
|
||||
setReaderWideState(!!on);
|
||||
try { localStorage.setItem('reader-wide', on ? '1' : '0'); } catch {}
|
||||
};
|
||||
// Typography and a second Bible shown beside the first — all per-device, like text size.
|
||||
const [readerFont, setReaderFont] = useStoredChoice('reader-font', 'serif', READER_FONTS);
|
||||
const [readerSpacing, setReaderSpacing] = useStoredChoice('reader-spacing', 'normal', READER_SPACINGS);
|
||||
const [readerVerseNumbers, setReaderVerseNumbersRaw] = useStoredChoice('reader-verse-numbers', 'on', ['on', 'off']);
|
||||
const [readerParallel, setReaderParallel] = useStoredChoice('reader-parallel', '', ['', ...READER_TRANSLATIONS.map((t) => t.id)]);
|
||||
// The page the app opens to, and where every Home button goes: 'studies' | 'reader' | 'plans'
|
||||
const [startPagePref, setStartPagePrefState] = useState(() => {
|
||||
try {
|
||||
@@ -136,6 +149,21 @@ export function useBibleReader(currentPage, translationOverride = null) {
|
||||
return Array.isArray(val) ? val : [];
|
||||
} catch { return []; }
|
||||
});
|
||||
// What each highlight colour means to this reader ({ '#fef08a': 'Promises' }); follows the account.
|
||||
const [readerColorLabels, setReaderColorLabelsState] = useState(() => {
|
||||
try {
|
||||
const val = JSON.parse(localStorage.getItem('reader-color-labels') || '{}');
|
||||
return val && typeof val === 'object' && !Array.isArray(val) ? val : {};
|
||||
} catch { return {}; }
|
||||
});
|
||||
const setReaderColorLabel = (color, label) => {
|
||||
setReaderColorLabelsState((prev) => {
|
||||
const next = { ...prev };
|
||||
if (label.trim()) next[color] = label.slice(0, 30); else delete next[color];
|
||||
try { localStorage.setItem('reader-color-labels', JSON.stringify(next)); } catch {}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
// Debounce timer ref so rapid stroke updates don't flood the server
|
||||
const inkSaveTimerRef = useRef({});
|
||||
|
||||
@@ -210,38 +238,25 @@ export function useBibleReader(currentPage, translationOverride = null) {
|
||||
|
||||
// BSB comes straight from HelloAO; licensed translations (NLT/CSB/NASB) go
|
||||
// through the server's api.bible proxy so the API key never reaches the browser.
|
||||
// The most recent request wins: flipping chapters quickly (or changing translation) must never let a
|
||||
// slow earlier response replace the page you are now on.
|
||||
const chapterRequestRef = useRef(0);
|
||||
const loadReaderChapter = async (bookAbbrev, chapterNumber, translation = readerTranslation) => {
|
||||
const requestId = ++chapterRequestRef.current;
|
||||
setReaderLoading(true);
|
||||
setReaderError('');
|
||||
try {
|
||||
let verses;
|
||||
let copyright = '';
|
||||
let totalChapters = CHAPTER_COUNTS[bookAbbrev] ?? chapterNumber;
|
||||
const free = READER_TRANSLATIONS.find((t) => t.id === translation && !t.licensed);
|
||||
if (free) {
|
||||
const res = await fetch(`https://bible.helloao.org/api/${free.helloao ?? free.id}/${bookAbbrev}/${chapterNumber}.json`);
|
||||
if (!res.ok) throw new Error('Unable to load chapter.');
|
||||
const data = await res.json();
|
||||
verses = parseBibleChapter(data);
|
||||
totalChapters = data.book?.numberOfChapters ?? totalChapters;
|
||||
} else {
|
||||
const res = await fetch(`/api/bible/${translation}/${bookAbbrev}/${chapterNumber}`, { credentials: 'include' });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || `Unable to load ${translation}.`);
|
||||
verses = data.verses;
|
||||
copyright = data.copyright ?? '';
|
||||
}
|
||||
if (!Array.isArray(verses) || verses.length === 0) {
|
||||
throw new Error('Invalid Bible data returned.');
|
||||
}
|
||||
const { verses, copyright, totalChapters } = await fetchChapter(translation, bookAbbrev, chapterNumber);
|
||||
if (requestId !== chapterRequestRef.current) return;
|
||||
setReaderVerses(verses);
|
||||
setReaderCopyright(copyright);
|
||||
setReaderTotalChapters(totalChapters);
|
||||
} catch (err) {
|
||||
if (requestId !== chapterRequestRef.current) return;
|
||||
setReaderError(err.message || 'Failed to load chapter.');
|
||||
setReaderVerses([]);
|
||||
} finally {
|
||||
setReaderLoading(false);
|
||||
if (requestId === chapterRequestRef.current) setReaderLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -256,7 +271,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
|
||||
if (readerJumpVerse == null || readerLoading) return;
|
||||
const el = document.getElementById(`reader-verse-${readerJumpVerse}`);
|
||||
if (!el) return;
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
el.scrollIntoView({ behavior: window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth', block: 'center' });
|
||||
const prevTransition = el.style.transition;
|
||||
const prevBackground = el.style.backgroundColor;
|
||||
el.style.transition = 'background-color 0.4s';
|
||||
@@ -347,6 +362,16 @@ export function useBibleReader(currentPage, translationOverride = null) {
|
||||
});
|
||||
};
|
||||
|
||||
// Puts a removed bookmark back with its colour (used by "Undo").
|
||||
const restoreReaderBookmark = (verseKey, color) => {
|
||||
setReaderBookmarks((prev) => {
|
||||
if (prev[verseKey]) return prev;
|
||||
const next = { ...prev, [verseKey]: color };
|
||||
try { localStorage.setItem('reader-bookmarks', JSON.stringify(next)); } catch {}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const updateReaderPageInk = useCallback((bookAbbrev, chapter, strokes) => {
|
||||
const key = `${bookAbbrev}_${chapter}`;
|
||||
setReaderInkByPage((prev) => {
|
||||
@@ -385,6 +410,16 @@ export function useBibleReader(currentPage, translationOverride = null) {
|
||||
});
|
||||
};
|
||||
|
||||
// Puts a removed highlight back exactly as it was (used by "Undo").
|
||||
const restoreTextHighlight = (highlight) => {
|
||||
setReaderTextHighlights((prev) => {
|
||||
if (prev.some((h) => h.id === highlight.id)) return prev;
|
||||
const next = [...prev, highlight];
|
||||
try { localStorage.setItem('readerTextHighlights', JSON.stringify(next)); } catch {}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const removeTextHighlight = (id) => {
|
||||
setReaderTextHighlights(prev => {
|
||||
const next = prev.filter(h => h.id !== id);
|
||||
@@ -417,6 +452,30 @@ export function useBibleReader(currentPage, translationOverride = null) {
|
||||
}
|
||||
};
|
||||
|
||||
// Bookmarks, highlights, colour names and ink from a backup file are added to what is here (nothing is
|
||||
// removed, and where both have something the copy already here wins).
|
||||
const importReaderMarks = ({ bookmarks, highlights, colorLabels, ink }) => {
|
||||
const merged = mergeMarks(normalizeMarks({ bookmarks, highlights }), normalizeMarks({ bookmarks: readerBookmarks, highlights: readerTextHighlights }));
|
||||
setReaderBookmarks(merged.bookmarks);
|
||||
setReaderTextHighlights(merged.highlights);
|
||||
try {
|
||||
localStorage.setItem('reader-bookmarks', JSON.stringify(merged.bookmarks));
|
||||
localStorage.setItem('readerTextHighlights', JSON.stringify(merged.highlights));
|
||||
} catch {}
|
||||
if (colorLabels && Object.keys(colorLabels).length > 0) {
|
||||
setReaderColorLabelsState((prev) => {
|
||||
const next = { ...colorLabels, ...prev };
|
||||
try { localStorage.setItem('reader-color-labels', JSON.stringify(next)); } catch {}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
// Ink goes through the normal save so it also reaches the account
|
||||
for (const [key, strokes] of Object.entries(ink ?? {})) {
|
||||
const [book, chapter] = key.split('_');
|
||||
if (!readerInkByPage[key] && book && chapter && Array.isArray(strokes)) updateReaderPageInk(book, chapter, strokes);
|
||||
}
|
||||
};
|
||||
|
||||
// Merges reader ink loaded from the server at sign-in; server wins over local.
|
||||
const mergeServerInk = useCallback((serverInk) => {
|
||||
setReaderInkByPage((local) => {
|
||||
@@ -438,6 +497,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,
|
||||
},
|
||||
apply: (key, v) => {
|
||||
if (key === 'theme') { setReaderThemeState(v); try { localStorage.setItem('reader-theme', v); } catch {} }
|
||||
@@ -446,6 +506,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
|
||||
else if (key === 'startPage') { setStartPagePrefState(v); try { localStorage.setItem('start-page', v); } catch {} }
|
||||
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 === 'position') { setReaderBookAbbrev(v.book); setReaderChapter(v.chapter); setReaderSelectedVerse(null); }
|
||||
},
|
||||
canApplyPosition: () => pageRef.current !== 'reader',
|
||||
@@ -467,6 +528,10 @@ export function useBibleReader(currentPage, translationOverride = null) {
|
||||
readerSelectedVerse, setReaderSelectedVerse,
|
||||
readerFontSize, setReaderFontSize,
|
||||
readerWide, setReaderWide,
|
||||
readerFont, setReaderFont,
|
||||
readerSpacing, setReaderSpacing,
|
||||
readerVerseNumbers: readerVerseNumbers === 'on', setReaderVerseNumbers: (on) => setReaderVerseNumbersRaw(on ? 'on' : 'off'),
|
||||
readerParallel, setReaderParallel,
|
||||
planTranslationPref, setPlanTranslationPref,
|
||||
startPagePref, setStartPagePref,
|
||||
readerBookmarks, setReaderBookmarks,
|
||||
@@ -480,11 +545,14 @@ export function useBibleReader(currentPage, translationOverride = null) {
|
||||
readerInkByPage,
|
||||
updateReaderPageInk,
|
||||
mergeServerInk,
|
||||
importReaderMarks,
|
||||
readerTextHighlights,
|
||||
...marksSync,
|
||||
...prefsSync,
|
||||
addTextHighlight,
|
||||
removeTextHighlight,
|
||||
restoreTextHighlight,
|
||||
readerColorLabels, setReaderColorLabel,
|
||||
bibleIndexStatusFor,
|
||||
loadBibleIndex,
|
||||
_bibleIndexCacheRef,
|
||||
@@ -494,6 +562,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
|
||||
readerGoToNextChapter,
|
||||
jumpToReaderVerse,
|
||||
toggleReaderBookmark,
|
||||
restoreReaderBookmark,
|
||||
cycleBookmarkColor,
|
||||
loadReaderCrossRefs,
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// active chapter tab, and every way of creating chunks (click, shift-click,
|
||||
// typed range, bulk ranges, cross-chapter spans), plus reorder and delete.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useToast } from '../components/Toasts.jsx';
|
||||
import { bookOptions, CHAPTER_COUNTS, makeId, parseBibleChapter } from '../utils/bibleUtils.jsx';
|
||||
|
||||
// Every chunk starts blank; all note fields exist from the start.
|
||||
@@ -99,6 +100,7 @@ function initialChapterIndex(project) {
|
||||
}
|
||||
|
||||
export function useChunkBuilder({ project, setProject, updateProject, allChunks, setCurrentPage }) {
|
||||
const { toast } = useToast();
|
||||
const [setup, setSetup] = useState(() => initialSetupForm(project));
|
||||
const [titleEdited, setTitleEdited] = useState(false);
|
||||
const [activeChapterIndex, setActiveChapterIndex] = useState(() => initialChapterIndex(project));
|
||||
@@ -531,7 +533,33 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
|
||||
}));
|
||||
};
|
||||
|
||||
// Deleting a chunk also deletes its notes, so it can be taken back for a few seconds.
|
||||
const deleteChunk = (chunkId) => {
|
||||
let removed = null;
|
||||
(project?.chapters ?? []).forEach((ch, chapterIndex) => {
|
||||
const index = ch.chunks.findIndex((c) => c.id === chunkId);
|
||||
if (index >= 0) removed = { chunk: ch.chunks[index], chapterIndex, index, wasSelected: project.selectedChunkId === chunkId };
|
||||
});
|
||||
if (removed) {
|
||||
toast('Chunk deleted', {
|
||||
action: {
|
||||
label: 'Undo',
|
||||
onClick: () => updateProject((current) => {
|
||||
if (current.chapters.some((ch) => ch.chunks.some((c) => c.id === chunkId))) return current;
|
||||
return {
|
||||
...current,
|
||||
selectedChunkId: removed.wasSelected ? chunkId : current.selectedChunkId,
|
||||
chapters: current.chapters.map((ch, i) => {
|
||||
if (i !== removed.chapterIndex) return ch;
|
||||
const chunks = [...ch.chunks];
|
||||
chunks.splice(Math.min(removed.index, chunks.length), 0, removed.chunk);
|
||||
return { ...ch, chunks };
|
||||
}),
|
||||
};
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
updateProject((current) => {
|
||||
const newAllChunks = current.chapters
|
||||
.flatMap((ch) => ch.chunks)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// Makes the browser's (and phone's) Back/Forward buttons move between the app's pages. The app keeps
|
||||
// its page in state rather than in the URL, so without this Back would leave the app altogether.
|
||||
// Each page change adds a history entry; Back/Forward set the page again from that entry.
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
// Pages that only make sense with a study project open.
|
||||
export const NEEDS_PROJECT = ['study', 'outline', 'script', 'export'];
|
||||
|
||||
export function useHistoryNav({ page, setPage, hasProject, onArrive, enabled = true }) {
|
||||
const shown = useRef(page);
|
||||
const hasProjectRef = useRef(hasProject);
|
||||
hasProjectRef.current = hasProject;
|
||||
const onArriveRef = useRef(onArrive);
|
||||
onArriveRef.current = onArrive;
|
||||
|
||||
// First entry: record where the app started so Back can return to it.
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
try { window.history.replaceState({ page }, ''); } catch { /* history unavailable */ }
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// A page change made inside the app adds a history entry.
|
||||
useEffect(() => {
|
||||
if (!enabled || shown.current === page) return;
|
||||
shown.current = page;
|
||||
try { window.history.pushState({ page }, ''); } catch { /* history unavailable */ }
|
||||
}, [page, enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return undefined;
|
||||
const onPop = (e) => {
|
||||
let target = e.state?.page;
|
||||
if (!target) return; // an entry that isn't ours (e.g. a hash change)
|
||||
if (NEEDS_PROJECT.includes(target) && !hasProjectRef.current) target = 'home';
|
||||
shown.current = target; // so the effect above doesn't push this page again
|
||||
setPage(target);
|
||||
onArriveRef.current?.(target);
|
||||
};
|
||||
window.addEventListener('popstate', onPop);
|
||||
return () => window.removeEventListener('popstate', onPop);
|
||||
}, [enabled, setPage]);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Drives "Save for offline": one download at a time, with progress for the button to show,
|
||||
// a cancel, and a toast when it finishes.
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useToast } from '../components/Toasts.jsx';
|
||||
import { bibleRefs, bookRefs, downloadChapters, loadSavedBooks, markBookSaved } from '../utils/offline.js';
|
||||
|
||||
export function useOnline() {
|
||||
const [online, setOnline] = useState(() => (typeof navigator === 'undefined' ? true : navigator.onLine !== false));
|
||||
useEffect(() => {
|
||||
const up = () => setOnline(true);
|
||||
const down = () => setOnline(false);
|
||||
window.addEventListener('online', up);
|
||||
window.addEventListener('offline', down);
|
||||
return () => { window.removeEventListener('online', up); window.removeEventListener('offline', down); };
|
||||
}, []);
|
||||
return online;
|
||||
}
|
||||
|
||||
export function useOfflineSave() {
|
||||
const { toast } = useToast();
|
||||
const [saved, setSaved] = useState(loadSavedBooks);
|
||||
const [job, setJob] = useState(null); // { label, done, total } while downloading
|
||||
const controller = useRef(null);
|
||||
useEffect(() => () => controller.current?.abort(), []);
|
||||
|
||||
const run = useCallback(async (label, translation, refs, { bookKeys = [] } = {}) => {
|
||||
if (controller.current) return; // one at a time
|
||||
const abort = new AbortController();
|
||||
controller.current = abort;
|
||||
setJob({ label, done: 0, total: refs.length });
|
||||
const result = await downloadChapters(translation, refs, { signal: abort.signal, onProgress: (done, total) => setJob({ label, done, total }) });
|
||||
controller.current = null;
|
||||
setJob(null);
|
||||
if (abort.signal.aborted) { toast('Download stopped'); return; }
|
||||
if (result.failed === 0) {
|
||||
bookKeys.forEach((b) => markBookSaved(translation, b));
|
||||
setSaved(loadSavedBooks());
|
||||
toast(`Saved ${label} for offline`);
|
||||
} else {
|
||||
toast(`Saved ${result.done} of ${refs.length} chapters — check your connection and try again`, { tone: 'error' });
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
return {
|
||||
saved, job,
|
||||
isSaved: (translation, book) => (saved[translation] ?? []).includes(book),
|
||||
saveBook: (translation, book, name) => run(name, translation, bookRefs(book), { bookKeys: [book] }),
|
||||
saveBible: (translation) => run(`The whole ${translation}`, translation, bibleRefs(), { bookKeys: [...new Set(bibleRefs().map((r) => r.book))] }),
|
||||
saveRefs: (label, translation, refs) => run(label, translation, refs),
|
||||
cancel: () => controller.current?.abort(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// The second Bible in the side-by-side view: loads the same chapter in another translation.
|
||||
// Pass an empty translation to switch it off.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { fetchChapter } from '../utils/bibleText.js';
|
||||
|
||||
export function useParallelChapter(translation, bookAbbrev, chapter) {
|
||||
const [state, setState] = useState({ key: '', verses: [], error: '' });
|
||||
const key = translation ? `${translation}/${bookAbbrev}/${chapter}` : '';
|
||||
|
||||
useEffect(() => {
|
||||
if (!key) return undefined;
|
||||
const controller = new AbortController();
|
||||
fetchChapter(translation, bookAbbrev, chapter, { signal: controller.signal })
|
||||
.then(({ verses }) => setState({ key, verses, error: '' }))
|
||||
.catch((err) => { if (!controller.signal.aborted) setState({ key, verses: [], error: err.message || 'Unable to load.' }); });
|
||||
return () => controller.abort();
|
||||
}, [key]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const ready = state.key === key;
|
||||
return {
|
||||
verses: ready ? state.verses : [],
|
||||
error: ready ? state.error : '',
|
||||
loading: !!key && !ready,
|
||||
};
|
||||
}
|
||||
@@ -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'];
|
||||
export const PREF_KEYS = ['theme', 'translation', 'layout', 'commentarySource', 'planTranslation', 'startPage', 'position', 'colorLabels'];
|
||||
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, 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, push]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const pullServerPrefs = useCallback(async () => {
|
||||
enabledRef.current = true;
|
||||
@@ -69,6 +69,7 @@ export function usePrefsSync({ values, apply, canApplyPosition = () => true }) {
|
||||
const meta = readMeta();
|
||||
const now = Date.now();
|
||||
for (const key of PREF_KEYS) {
|
||||
if (valuesRef.current[key] === undefined) continue; // nothing on this device to compare or upload
|
||||
const remote = server[key];
|
||||
const localAt = meta[key] ?? 0;
|
||||
if (remote && remote.at > localAt) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// The signed-in user's saved projects: opening, renaming, deleting, and keeping
|
||||
// this device's copies in step with the server.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useToast } from '../components/Toasts.jsx';
|
||||
import { clearSyncBase, getSyncBase, setSyncBase } from '../utils/syncBase.js';
|
||||
import { loadProjectIndex } from '../utils/bibleUtils.jsx';
|
||||
import {
|
||||
saveProjectToStorage,
|
||||
@@ -21,6 +23,9 @@ export function useProjectLibrary({
|
||||
setProjectIndex,
|
||||
setCurrentPage,
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
// Deleted projects waiting out their "Undo" window before the server copy is removed too
|
||||
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
|
||||
|
||||
@@ -39,11 +44,17 @@ export function useProjectLibrary({
|
||||
listRemoteProjects().then(async (result) => {
|
||||
if (!result.ok || cancelled) return;
|
||||
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.
|
||||
for (const e of result.data) {
|
||||
const local = localMap.get(e.id);
|
||||
setSyncBase(e.id, local ? Math.min(e.lastEdited ?? 0, local.lastEdited ?? 0) : e.lastEdited);
|
||||
}
|
||||
|
||||
const missing = result.data.filter((e) => !localMap.has(e.id));
|
||||
if (missing.length > 0) {
|
||||
const fetched = await Promise.all(missing.map((e) => loadRemoteProject(e.id)));
|
||||
fetched.filter((r) => r.ok).forEach((r) => saveProjectToStorage(r.data));
|
||||
fetched.filter((r) => r.ok).forEach((r) => { saveProjectToStorage(r.data); setSyncBase(r.data.id, r.data.lastEdited); });
|
||||
if (cancelled) return;
|
||||
const restoredCount = fetched.filter((r) => r.ok).length;
|
||||
if (restoredCount > 0) {
|
||||
@@ -78,26 +89,57 @@ export function useProjectLibrary({
|
||||
const updated = { ...loaded, title, lastEdited: Date.now() };
|
||||
saveProjectToStorage(updated);
|
||||
setProjectIndex(loadProjectIndex());
|
||||
saveRemoteProject(updated);
|
||||
saveRemoteProject(updated, { baseEdited: getSyncBase(id) }).then((result) => {
|
||||
if (result.ok) setSyncBase(id, updated.lastEdited);
|
||||
else if (result.status === 409) toast('That study changed on another device. Open it to choose which version to keep.', { tone: 'error' });
|
||||
});
|
||||
if (project?.id === id) {
|
||||
setProject((current) => (current ? { ...current, title } : current));
|
||||
}
|
||||
};
|
||||
|
||||
const deleteProject = (id) => {
|
||||
if (!window.confirm('Delete this project? This cannot be undone.')) return;
|
||||
deleteProjectFromStorage(id);
|
||||
setProjectIndex(loadProjectIndex());
|
||||
// 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) => {
|
||||
if (!pendingDeletes.current.has(id)) return;
|
||||
window.clearTimeout(pendingDeletes.current.get(id));
|
||||
pendingDeletes.current.delete(id);
|
||||
deleteRemoteProject(id); // fire-and-forget
|
||||
};
|
||||
useEffect(() => {
|
||||
const flush = () => [...pendingDeletes.current.keys()].forEach(finishDelete);
|
||||
window.addEventListener('pagehide', flush);
|
||||
return () => window.removeEventListener('pagehide', flush);
|
||||
}, []);
|
||||
|
||||
const deleteProject = (id) => {
|
||||
const loaded = loadProjectById(id);
|
||||
if (!loaded) return;
|
||||
deleteProjectFromStorage(id);
|
||||
clearSyncBase(id);
|
||||
setProjectIndex(loadProjectIndex());
|
||||
pendingDeletes.current.set(id, window.setTimeout(() => finishDelete(id), 8000));
|
||||
toast(`Deleted “${loaded.title}”`, {
|
||||
action: {
|
||||
label: 'Undo',
|
||||
onClick: () => {
|
||||
window.clearTimeout(pendingDeletes.current.get(id));
|
||||
pendingDeletes.current.delete(id);
|
||||
saveProjectToStorage(loaded);
|
||||
setProjectIndex(loadProjectIndex());
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const pullLatestFromServer = async (id) => {
|
||||
const result = await loadRemoteProject(id);
|
||||
if (!result.ok) {
|
||||
alert('Could not pull latest version from server.');
|
||||
toast('Could not pull the latest version from the server.', { tone: 'error' });
|
||||
return;
|
||||
}
|
||||
saveProjectToStorage(result.data);
|
||||
setSyncBase(id, result.data.lastEdited);
|
||||
setProjectIndex(loadProjectIndex());
|
||||
setStaleLocalProjects((prev) => prev.filter((e) => e.id !== id));
|
||||
};
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
// shown, instead of being a fire-and-forget call buried inside setState.
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { getRemoteReadingProgress, saveRemoteReadingProgress } from '../syncService.js';
|
||||
import { useToast } from '../components/Toasts.jsx';
|
||||
import { mergeReadingPlans } from '../utils/backup.js';
|
||||
import { clampPlanDays, localDateString, normalizeCustomPlan, registerCustomPlans } from '../utils/readingPlans.js';
|
||||
|
||||
const STORAGE_KEY = 'readingPlanProgress';
|
||||
@@ -33,7 +35,10 @@ function formatSyncError(result) {
|
||||
}
|
||||
|
||||
export function useReadingPlans() {
|
||||
const { toast } = useToast();
|
||||
const [state, setState] = useState(loadLocal);
|
||||
const stateRef = useRef(state); // the latest state, for taking a copy just before something is removed
|
||||
stateRef.current = state;
|
||||
const [syncStatus, setSyncStatus] = useState(''); // '' | 'syncing' | 'synced' | 'error'
|
||||
const [syncError, setSyncError] = useState('');
|
||||
// The exact object loadLocal() produced at mount. Compared by reference (not
|
||||
@@ -167,17 +172,30 @@ export function useReadingPlans() {
|
||||
updateProgress((prev) => prev.map((p) => (p.id === progressId ? { ...p, translation } : p)));
|
||||
}, [updateProgress]);
|
||||
|
||||
// Restarting and removing are immediate, with an "Undo" toast that puts the plan back exactly as it was.
|
||||
const restartPlan = useCallback((progressId) => {
|
||||
const before = stateRef.current.progress.find((p) => p.id === progressId);
|
||||
updateProgress((prev) => prev.map((p) => (
|
||||
p.id === progressId
|
||||
? { ...p, completedDays: [], chaptersRead: {}, completedAt: null, startedAt: Date.now(), lastActivityAt: Date.now() }
|
||||
: p
|
||||
)));
|
||||
}, [updateProgress]);
|
||||
if (before) toast('Plan restarted from day 1', { action: { label: 'Undo', onClick: () => updateProgress((prev) => prev.map((p) => (p.id === progressId ? before : p))) } });
|
||||
}, [updateProgress, toast]);
|
||||
|
||||
const removePlanProgress = useCallback((progressId) => {
|
||||
const index = stateRef.current.progress.findIndex((p) => p.id === progressId);
|
||||
const before = stateRef.current.progress[index];
|
||||
updateProgress((prev) => prev.filter((p) => p.id !== progressId));
|
||||
}, [updateProgress]);
|
||||
if (before) {
|
||||
toast('Plan removed', {
|
||||
action: {
|
||||
label: 'Undo',
|
||||
onClick: () => updateProgress((prev) => (prev.some((p) => p.id === progressId) ? prev : [...prev.slice(0, index), before, ...prev.slice(index)])),
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [updateProgress, toast]);
|
||||
|
||||
// Resets the pace clock so a plan that's fallen behind schedule stops being
|
||||
// flagged as "behind" — doesn't touch which days are actually marked done.
|
||||
@@ -212,9 +230,22 @@ export function useReadingPlans() {
|
||||
}, [updateCustomPlans]);
|
||||
|
||||
const removeCustomPlan = useCallback((planId) => {
|
||||
const plan = stateRef.current.customPlans.find((p) => p.id === planId);
|
||||
const runs = stateRef.current.progress.filter((p) => p.planId === planId);
|
||||
updateCustomPlans((prev) => prev.filter((p) => p.id !== planId));
|
||||
updateProgress((prev) => prev.filter((p) => p.planId !== planId));
|
||||
}, [updateCustomPlans, updateProgress]);
|
||||
if (plan) {
|
||||
toast(`Deleted “${plan.title}”`, {
|
||||
action: {
|
||||
label: 'Undo',
|
||||
onClick: () => {
|
||||
updateCustomPlans((prev) => (prev.some((p) => p.id === planId) ? prev : [plan, ...prev]));
|
||||
updateProgress((prev) => [...runs.filter((r) => !prev.some((p) => p.id === r.id)), ...prev]);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [updateCustomPlans, updateProgress, toast]);
|
||||
|
||||
// Patches an existing custom plan. Pass resetProgress: true when the edit
|
||||
// changes its books/days (day indices from before the edit no longer mean
|
||||
@@ -230,6 +261,11 @@ export function useReadingPlans() {
|
||||
}
|
||||
}, [updateCustomPlans, updateProgress]);
|
||||
|
||||
// Plans from a backup file are added to what is here (nothing is removed).
|
||||
const importReadingPlans = useCallback((incoming) => {
|
||||
setState((prev) => ({ ...prev, ...mergeReadingPlans(prev, incoming) }));
|
||||
}, []);
|
||||
|
||||
// Server wins over whatever's local at sign-in.
|
||||
const mergeServerReadingProgress = useCallback((serverData) => {
|
||||
if (!serverData || typeof serverData !== 'object' || Array.isArray(serverData)) {
|
||||
@@ -280,6 +316,8 @@ export function useReadingPlans() {
|
||||
}, [state]);
|
||||
|
||||
return {
|
||||
readingPlansState: state,
|
||||
importReadingPlans,
|
||||
planProgress: state.progress,
|
||||
customPlans,
|
||||
readingActivity: state.activity,
|
||||
|
||||
Reference in new issue
Block a user