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

+65
View File
@@ -0,0 +1,65 @@
// Auth accepts only these names (AppearanceRequest); style.css has a preset for each.
export const ACCENTS = ['violet', 'blue', 'green', 'orange', 'pink', 'red'];
export const CORNERS = ['sharp', 'default', 'round'];
export const DEFAULT_APPEARANCE = {accent: 'violet', corners: 'default', wideLayout: false, backgroundAppid: null};
const STORAGE_KEY = 'tbp_appearance';
const REQUEST_TIMEOUT_MS = 10000;
export const normalizeAppearance = (value) => ({
accent: ACCENTS.includes(value?.accent) ? value.accent : DEFAULT_APPEARANCE.accent,
corners: CORNERS.includes(value?.corners) ? value.corners : DEFAULT_APPEARANCE.corners,
wideLayout: value?.wideLayout === true,
backgroundAppid: Number.isInteger(value?.backgroundAppid) && value.backgroundAppid > 0 ? value.backgroundAppid : null,
});
/**
* The last known appearance, so the first paint does not flash the default look before Auth answers.
*/
export const readCachedAppearance = () => {
try {
return normalizeAppearance(JSON.parse(localStorage.getItem(STORAGE_KEY)));
} catch {
return DEFAULT_APPEARANCE;
}
};
export const cacheAppearance = (appearance) => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(appearance));
} catch {
// Private mode or a full storage: the look is only not cached.
}
};
export const applyAppearance = (appearance, root = document.documentElement) => {
root.dataset.accent = appearance.accent;
root.dataset.corners = appearance.corners;
};
/**
* PUT /api/users/settings/appearance on Auth. The caller handles the response status.
*/
export const saveAppearance = (appearance) =>
fetch(`${import.meta.env.VITE_API_URL}/api/users/settings/appearance`, {
method: 'PUT',
headers: {'Authorization': `Bearer ${localStorage.getItem('tbp_jwt')}`, 'Content-Type': 'application/json'},
body: JSON.stringify(appearance),
// Saves run one after another; a hung request must not hold back the next ones.
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
/**
* GET /api/users/me on Auth, only for its appearance. Null when there is no answer to use.
*/
export const loadAppearance = async () => {
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, {
headers: {'Authorization': `Bearer ${localStorage.getItem('tbp_jwt')}`},
// Saves wait for this load.
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) return null;
const data = await response.json();
return data.settings?.appearance ? normalizeAppearance(data.settings.appearance) : null;
};
+52
View File
@@ -0,0 +1,52 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it} from 'vitest';
import {
applyAppearance,
cacheAppearance,
DEFAULT_APPEARANCE,
normalizeAppearance,
readCachedAppearance,
} from './appearance.js';
describe('normalizeAppearance', () => {
it('keeps known values', () => {
const appearance = {accent: 'blue', corners: 'round', wideLayout: true, backgroundAppid: 620};
expect(normalizeAppearance(appearance)).toEqual(appearance);
});
it('replaces unknown or missing values with the defaults', () => {
expect(normalizeAppearance({accent: 'red;x', corners: 'pointy', wideLayout: 'yes', backgroundAppid: -1}))
.toEqual(DEFAULT_APPEARANCE);
expect(normalizeAppearance(null)).toEqual(DEFAULT_APPEARANCE);
expect(normalizeAppearance({backgroundAppid: 1.5})).toEqual(DEFAULT_APPEARANCE);
});
});
describe('appearance cache', () => {
afterEach(() => localStorage.clear());
it('reads back what was cached', () => {
cacheAppearance({accent: 'green', corners: 'sharp', wideLayout: false, backgroundAppid: null});
expect(readCachedAppearance()).toEqual({accent: 'green', corners: 'sharp', wideLayout: false, backgroundAppid: null});
});
it('falls back to the defaults for a broken or empty cache', () => {
expect(readCachedAppearance()).toEqual(DEFAULT_APPEARANCE);
localStorage.setItem('tbp_appearance', '{broken');
expect(readCachedAppearance()).toEqual(DEFAULT_APPEARANCE);
});
});
describe('applyAppearance', () => {
it('sets the presets as data attributes for the CSS', () => {
const root = document.createElement('html');
applyAppearance({accent: 'orange', corners: 'round', wideLayout: true, backgroundAppid: 620}, root);
expect(root.dataset.accent).toBe('orange');
expect(root.dataset.corners).toBe('round');
});
});
+3
View File
@@ -12,3 +12,6 @@ export const formatPlaytime = (minutes) => {
// 460x215 store header. Not every app has one; callers need an onError fallback.
export const coverUrl = (appid) => `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/header.jpg`;
// 1920x620 library art. Older apps have none; callers need an onError fallback.
export const heroUrl = (appid) => `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/library_hero.jpg`;