Public Access
173 lines
6.6 KiB
React
173 lines
6.6 KiB
React
import {createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState} from 'react';
|
|
import {useTranslation} from 'react-i18next';
|
|
import {
|
|
applyAppearance,
|
|
cacheAppearance,
|
|
DEFAULT_APPEARANCE,
|
|
loadPreferences,
|
|
normalizeAppearance,
|
|
readCachedAppearance,
|
|
saveAppearance,
|
|
} from '../utils/appearance.js';
|
|
import {hasSession} from '../utils/auth.js';
|
|
import {LANGUAGES, saveSettings} from '../utils/settings.js';
|
|
|
|
const DARK_MODE_STORAGE_KEY = 'tbp_dark_mode';
|
|
// The --background of style.css; the installed app paints its title bar in this color.
|
|
const THEME_COLORS = {dark: '#09090b', light: '#fafafa'};
|
|
|
|
const PreferencesContext = createContext(null);
|
|
|
|
export function PreferencesProvider({children}) {
|
|
const {i18n} = useTranslation();
|
|
|
|
const [isDarkMode, setIsDarkMode] = useState(() => {
|
|
const stored = localStorage.getItem(DARK_MODE_STORAGE_KEY);
|
|
return stored === null ? true : stored === 'true';
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (isDarkMode) {
|
|
document.documentElement.classList.add('dark');
|
|
} else {
|
|
document.documentElement.classList.remove('dark');
|
|
}
|
|
document.querySelector('meta[name="theme-color"]')
|
|
?.setAttribute('content', isDarkMode ? THEME_COLORS.dark : THEME_COLORS.light);
|
|
localStorage.setItem(DARK_MODE_STORAGE_KEY, String(isDarkMode));
|
|
}, [isDarkMode]);
|
|
|
|
const [appearance, setAppearanceState] = useState(readCachedAppearance);
|
|
// What Auth has, as far as this tab knows. A save sends it plus the pending changes, never a stale full look.
|
|
const savedAppearance = useRef(appearance);
|
|
// Changed here but not yet saved; a load from Auth keeps them.
|
|
const pendingChanges = useRef({});
|
|
const syncing = useRef(Promise.resolve());
|
|
const saving = useRef(Promise.resolve());
|
|
// Grows on reset, so a load or save that started for the user before does not come back.
|
|
const generation = useRef(0);
|
|
|
|
// Layout effect: the cached look is set before the first paint.
|
|
useLayoutEffect(() => {
|
|
applyAppearance(appearance);
|
|
cacheAppearance(appearance);
|
|
}, [appearance]);
|
|
|
|
// Grows on every language pick, so a load that started before does not undo it.
|
|
const languagePicks = useRef(0);
|
|
|
|
const syncPreferences = useCallback(() => {
|
|
const started = generation.current;
|
|
const picksBefore = languagePicks.current;
|
|
syncing.current = (async () => {
|
|
try {
|
|
const loaded = await loadPreferences();
|
|
if (loaded?.appearance && generation.current === started) {
|
|
savedAppearance.current = loaded.appearance;
|
|
setAppearanceState(normalizeAppearance({...loaded.appearance, ...pendingChanges.current}));
|
|
}
|
|
if (LANGUAGES.includes(loaded?.language) && languagePicks.current === picksBefore
|
|
&& loaded.language !== i18n.language) {
|
|
i18n.changeLanguage(loaded.language);
|
|
}
|
|
} catch (err) {
|
|
console.error('Preferences load error:', err);
|
|
}
|
|
})();
|
|
return syncing.current;
|
|
}, [i18n]);
|
|
|
|
/**
|
|
* Applies the changed fields at once, then saves them to Auth after a running load and after the saves before
|
|
* it, one at a time, so answers cannot arrive out of order. On a failed save it puts back the fields that still
|
|
* hold this change, so a later change is kept.
|
|
*
|
|
* @return the fetch response, or null when a reset came first and nothing was sent; throws on a network error
|
|
*/
|
|
const changeAppearance = useCallback((changes) => {
|
|
const started = generation.current;
|
|
pendingChanges.current = {...pendingChanges.current, ...changes};
|
|
setAppearanceState((current) => normalizeAppearance({...current, ...changes}));
|
|
|
|
const save = async () => {
|
|
await syncing.current;
|
|
if (generation.current !== started) return null;
|
|
|
|
const body = normalizeAppearance({...savedAppearance.current, ...pendingChanges.current});
|
|
let response = null;
|
|
try {
|
|
response = await saveAppearance(body);
|
|
return response;
|
|
} finally {
|
|
if (generation.current === started) {
|
|
const ok = !!response?.ok;
|
|
if (ok) savedAppearance.current = body;
|
|
const own = Object.keys(changes).filter((field) => pendingChanges.current[field] === changes[field]);
|
|
own.forEach((field) => delete pendingChanges.current[field]);
|
|
if (!ok && own.length) {
|
|
setAppearanceState((current) => normalizeAppearance({
|
|
...current, ...Object.fromEntries(own.map((field) => [field, savedAppearance.current[field]])),
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
};
|
|
const result = saving.current.then(save, save);
|
|
saving.current = result.catch(() => {});
|
|
return result;
|
|
}, []);
|
|
|
|
const resetAppearance = useCallback(() => {
|
|
generation.current += 1;
|
|
pendingChanges.current = {};
|
|
savedAppearance.current = DEFAULT_APPEARANCE;
|
|
setAppearanceState(DEFAULT_APPEARANCE);
|
|
}, []);
|
|
|
|
/**
|
|
* Applies the language at once and saves it to Auth while logged in.
|
|
*
|
|
* @return the fetch response, or null when logged out; rejects on a network error
|
|
*/
|
|
const setLanguage = (lang) => {
|
|
languagePicks.current += 1;
|
|
i18n.changeLanguage(lang);
|
|
if (!hasSession()) return Promise.resolve(null);
|
|
return saveSettings({language: lang});
|
|
};
|
|
|
|
return (
|
|
<PreferencesContext.Provider
|
|
value={{
|
|
isDarkMode,
|
|
setDarkMode: setIsDarkMode,
|
|
language: i18n.language,
|
|
setLanguage,
|
|
appearance,
|
|
changeAppearance,
|
|
syncPreferences,
|
|
resetAppearance,
|
|
}}
|
|
>
|
|
{children}
|
|
</PreferencesContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function usePreferences() {
|
|
const ctx = useContext(PreferencesContext);
|
|
if (!ctx) {
|
|
throw new Error('usePreferences must be used inside <PreferencesProvider>');
|
|
}
|
|
return ctx;
|
|
}
|
|
|
|
/**
|
|
* resetAppearance, or a no-op outside a PreferencesProvider, so hooks used in bare component tests keep working.
|
|
*/
|
|
export function useResetAppearance() {
|
|
return useContext(PreferencesContext)?.resetAppearance ?? noop;
|
|
}
|
|
|
|
const noop = () => {};
|