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
@@ -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;
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
Reference in new issue
Block a user