feat(appearance): accent color, corners, wide layout and game art background

This commit is contained in:
lobo committed 2026-09-24 15:06:16 +02:00
1 parent 91d5672421
commit 2c360c9421
28 files changed
+934 -46

No files matched your search

+102 -1
View File
@@ -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 = () => {};