Public Access
feat(appearance): accent color, corners, wide layout and game art background
This commit is contained in:
1 parent
91d5672421
commit
2c360c9421
28 files changed
+934
-46
No files matched your search
@@ -1,5 +1,14 @@
|
||||
import {createContext, useContext, useEffect, useState} from 'react';
|
||||
import {createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {
|
||||
applyAppearance,
|
||||
cacheAppearance,
|
||||
DEFAULT_APPEARANCE,
|
||||
loadAppearance,
|
||||
normalizeAppearance,
|
||||
readCachedAppearance,
|
||||
saveAppearance,
|
||||
} from '../utils/appearance.js';
|
||||
|
||||
const DARK_MODE_STORAGE_KEY = 'tbp_dark_mode';
|
||||
|
||||
@@ -22,6 +31,85 @@ export function PreferencesProvider({children}) {
|
||||
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]);
|
||||
|
||||
const syncAppearance = useCallback(() => {
|
||||
const started = generation.current;
|
||||
syncing.current = (async () => {
|
||||
try {
|
||||
const loaded = await loadAppearance();
|
||||
if (loaded && generation.current === started) {
|
||||
savedAppearance.current = loaded;
|
||||
setAppearanceState(normalizeAppearance({...loaded, ...pendingChanges.current}));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Appearance load error:', err);
|
||||
}
|
||||
})();
|
||||
return syncing.current;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}, []);
|
||||
|
||||
const setLanguage = (lang) => i18n.changeLanguage(lang);
|
||||
|
||||
return (
|
||||
@@ -31,6 +119,10 @@ export function PreferencesProvider({children}) {
|
||||
setDarkMode: setIsDarkMode,
|
||||
language: i18n.language,
|
||||
setLanguage,
|
||||
appearance,
|
||||
changeAppearance,
|
||||
syncAppearance,
|
||||
resetAppearance,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
@@ -45,3 +137,12 @@ export function usePreferences() {
|
||||
}
|
||||
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 = () => {};
|
||||
Reference in new issue
Block a user