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>
115 lines
5.5 KiB
JavaScript
115 lines
5.5 KiB
JavaScript
// Keeps a few display preferences (theme, default translation, verse layout, commentary
|
|
// source, and where you left off in the Reader) in step with the signed-in account.
|
|
// Every preference has its own timestamp, so the newest change to each one wins and two
|
|
// devices changing different settings never overwrite each other.
|
|
import { useCallback, useEffect, useRef } from 'react';
|
|
import { getRemotePrefs, saveRemotePrefs } from '../syncService.js';
|
|
|
|
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;
|
|
|
|
const lsGet = (k) => { try { return localStorage.getItem(k); } catch { return null; } };
|
|
const lsSet = (k, v) => { try { localStorage.setItem(k, v); } catch { /* storage unavailable */ } };
|
|
const lsDel = (k) => { try { localStorage.removeItem(k); } catch { /* storage unavailable */ } };
|
|
const readMeta = () => { try { return JSON.parse(lsGet(META_KEY) || '{}'); } catch { return {}; } };
|
|
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
|
|
// values: { theme, translation, layout, commentarySource, planTranslation, startPage, position: { book, chapter } }
|
|
// apply(key, value): puts a server value on screen and in localStorage
|
|
// canApplyPosition(): false while the Reader is open, so a background sync never moves the page under you
|
|
export function usePrefsSync({ values, apply, canApplyPosition = () => true }) {
|
|
const valuesRef = useRef(values);
|
|
valuesRef.current = values;
|
|
const prevRef = useRef(values);
|
|
const enabledRef = useRef(false);
|
|
const dirtyRef = useRef(new Set());
|
|
const timerRef = useRef(null);
|
|
const lastPullRef = useRef(0);
|
|
const applyRef = useRef(apply);
|
|
applyRef.current = apply;
|
|
const canApplyPositionRef = useRef(canApplyPosition);
|
|
canApplyPositionRef.current = canApplyPosition;
|
|
|
|
const push = useCallback(async () => {
|
|
window.clearTimeout(timerRef.current);
|
|
if (!enabledRef.current || dirtyRef.current.size === 0) return;
|
|
const keys = [...dirtyRef.current];
|
|
const meta = readMeta();
|
|
const prefs = Object.fromEntries(keys.map((k) => [k, { v: valuesRef.current[k], at: meta[k] ?? Date.now() }]));
|
|
const result = await saveRemotePrefs(prefs);
|
|
if (!result.ok) return; // stays dirty; retried on the next change or when the tab regains focus
|
|
keys.forEach((k) => { if (readMeta()[k] === prefs[k].at) dirtyRef.current.delete(k); });
|
|
if (dirtyRef.current.size > 0) timerRef.current = window.setTimeout(push, PUSH_DELAY_MS);
|
|
}, []);
|
|
|
|
// Local changes: stamp them and save shortly.
|
|
useEffect(() => {
|
|
const prev = prevRef.current;
|
|
const changed = PREF_KEYS.filter((k) => !same(prev[k], values[k]));
|
|
prevRef.current = values;
|
|
if (changed.length === 0) return;
|
|
const meta = readMeta();
|
|
const now = Date.now();
|
|
changed.forEach((k) => { meta[k] = now; dirtyRef.current.add(k); });
|
|
lsSet(META_KEY, JSON.stringify(meta));
|
|
if (enabledRef.current) {
|
|
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
|
|
|
|
const pullServerPrefs = useCallback(async () => {
|
|
enabledRef.current = true;
|
|
lastPullRef.current = Date.now();
|
|
const result = await getRemotePrefs();
|
|
if (!result.ok) return;
|
|
const server = result.data?.prefs ?? {};
|
|
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) {
|
|
if (key === 'position' && !canApplyPositionRef.current()) continue; // don't move the page mid-read
|
|
meta[key] = remote.at;
|
|
if (!same(valuesRef.current[key], remote.v)) {
|
|
prevRef.current = { ...prevRef.current, [key]: remote.v }; // so this isn't mistaken for a local edit
|
|
applyRef.current(key, remote.v);
|
|
}
|
|
dirtyRef.current.delete(key);
|
|
} else if (!remote || localAt > remote.at) {
|
|
if (!meta[key]) meta[key] = now; // first time on this account: what's on screen becomes the account's
|
|
dirtyRef.current.add(key);
|
|
}
|
|
}
|
|
lsSet(META_KEY, JSON.stringify(meta));
|
|
await push();
|
|
}, [push]);
|
|
|
|
// Signed out: stop syncing. Values stay as they are on this device; the timestamps are
|
|
// forgotten so the next account to sign in here starts from its own settings.
|
|
const stopPrefsSync = useCallback(() => {
|
|
enabledRef.current = false;
|
|
window.clearTimeout(timerRef.current);
|
|
dirtyRef.current.clear();
|
|
lsDel(META_KEY);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const onVisibility = () => {
|
|
if (!enabledRef.current) return;
|
|
if (document.visibilityState === 'hidden') { push(); return; }
|
|
if (Date.now() - lastPullRef.current > FOCUS_PULL_MIN_MS) pullServerPrefs();
|
|
};
|
|
document.addEventListener('visibilitychange', onVisibility);
|
|
return () => document.removeEventListener('visibilitychange', onVisibility);
|
|
}, [push, pullServerPrefs]);
|
|
|
|
useEffect(() => () => window.clearTimeout(timerRef.current), []);
|
|
|
|
return { pullServerPrefs, stopPrefsSync, flushPrefs: push };
|
|
}
|