Public Access
refactor: item 29 cleanup
F16 F17 F18 F19 F20 F21: import polling backs off and pauses in a hidden tab, one date format module, fake timers in the import tests, error codes instead of raw messages, one safe storage wrapper, dead code.
This commit is contained in:
1 parent
aa5832225d
commit
89d0b29698
31 files changed
+238
-81
No files matched your search
@@ -1,5 +1,6 @@
|
||||
import {apiFetch} from './api.js';
|
||||
import {fetchMe, REQUEST_TIMEOUT_MS} from './settings.js';
|
||||
import {storage} from './storage.js';
|
||||
|
||||
// Auth accepts only these names (AppearanceRequest); style.css has a preset for each.
|
||||
export const ACCENTS = ['violet', 'blue', 'green', 'orange', 'pink', 'red'];
|
||||
@@ -28,19 +29,13 @@ export const normalizeAppearance = (value) => ({
|
||||
*/
|
||||
export const readCachedAppearance = () => {
|
||||
try {
|
||||
return normalizeAppearance(JSON.parse(localStorage.getItem(STORAGE_KEY)));
|
||||
return normalizeAppearance(JSON.parse(storage.get(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 cacheAppearance = (appearance) => storage.set(STORAGE_KEY, JSON.stringify(appearance));
|
||||
|
||||
export const applyAppearance = (appearance, root = document.documentElement) => {
|
||||
root.dataset.accent = appearance.accent;
|
||||
|
||||
+7
-10
@@ -1,15 +1,15 @@
|
||||
import {storage} from './storage.js';
|
||||
|
||||
// The tokens live in HttpOnly cookies that no script can read. This flag only remembers that a login happened; a
|
||||
// stale flag costs one 401 and a failed refresh, then the login page opens.
|
||||
export const SESSION_KEY = 'tbp_session';
|
||||
// Where the token lived before the cookies. Removed on the next login or logout.
|
||||
const LEGACY_TOKEN_KEY = 'tbp_jwt';
|
||||
|
||||
export const hasSession = () => localStorage.getItem(SESSION_KEY) !== null;
|
||||
export const hasSession = () => storage.get(SESSION_KEY) !== null;
|
||||
|
||||
/**
|
||||
* Changes on every login and on every settings save, so a hook that shows the user's Steam data can load again.
|
||||
*/
|
||||
export const sessionStamp = () => localStorage.getItem(SESSION_KEY);
|
||||
export const sessionStamp = () => storage.get(SESSION_KEY);
|
||||
|
||||
const listeners = new Set();
|
||||
// Why the session of this tab ended: null (never had one, or another tab ended it), 'logout' or 'expired'.
|
||||
@@ -28,14 +28,12 @@ const onStorage = (event) => {
|
||||
|
||||
export const markSession = () => {
|
||||
// New for every login, also two in the same millisecond: apiFetch tells sessions apart by it.
|
||||
localStorage.setItem(SESSION_KEY, crypto.randomUUID());
|
||||
localStorage.removeItem(LEGACY_TOKEN_KEY);
|
||||
storage.set(SESSION_KEY, crypto.randomUUID());
|
||||
change(null);
|
||||
};
|
||||
|
||||
export const clearSession = () => {
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
localStorage.removeItem(LEGACY_TOKEN_KEY);
|
||||
storage.remove(SESSION_KEY);
|
||||
change('logout');
|
||||
};
|
||||
|
||||
@@ -46,8 +44,7 @@ export const clearSession = () => {
|
||||
*/
|
||||
export const expireSession = (stamp = sessionStamp()) => {
|
||||
if (stamp === null || sessionStamp() !== stamp) return;
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
localStorage.removeItem(LEGACY_TOKEN_KEY);
|
||||
storage.remove(SESSION_KEY);
|
||||
change('expired');
|
||||
};
|
||||
|
||||
|
||||
@@ -15,16 +15,6 @@ describe('session flag', () => {
|
||||
expect(hasSession()).toBe(false);
|
||||
});
|
||||
|
||||
it('removes the token of the old localStorage login', () => {
|
||||
localStorage.setItem('tbp_jwt', 'old');
|
||||
markSession();
|
||||
expect(localStorage.getItem('tbp_jwt')).toBeNull();
|
||||
|
||||
localStorage.setItem('tbp_jwt', 'old');
|
||||
clearSession();
|
||||
expect(localStorage.getItem('tbp_jwt')).toBeNull();
|
||||
});
|
||||
|
||||
it('gives a new stamp on every mark', async () => {
|
||||
markSession();
|
||||
const first = sessionStamp();
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// A LocalDate from Core ("2026-09-27"), by default as a short day; noon, so no time zone moves it to the day before.
|
||||
export const formatDay = (day, locale, options = {day: 'numeric', month: 'short'}) =>
|
||||
new Date(`${day}T12:00:00`).toLocaleDateString(locale, options);
|
||||
|
||||
// An instant from the backend (ISO string or epoch millis) as a date, or as date and time.
|
||||
export const formatDate = (instant, locale, options) => new Date(instant).toLocaleDateString(locale, options);
|
||||
|
||||
export const formatDateTime = (instant, locale, options) => new Date(instant).toLocaleString(locale, options);
|
||||
@@ -0,0 +1,9 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {formatDay} from './format.js';
|
||||
|
||||
describe('formatDay', () => {
|
||||
it('formats a Core day as a short day, or with the given options', () => {
|
||||
expect(formatDay('2026-09-27', 'en-US')).toBe('Sep 27');
|
||||
expect(formatDay('2026-09-27', 'en-US', {year: 'numeric', month: 'long'})).toBe('September 2026');
|
||||
});
|
||||
});
|
||||
@@ -10,10 +10,6 @@ export const formatPlaytime = (minutes) => {
|
||||
return hours >= 10 ? `${Math.round(hours)}h` : `${hours.toFixed(1)}h`;
|
||||
};
|
||||
|
||||
// A LocalDate from Core ("2026-09-27") as a short day; noon, so no time zone moves it to the day before.
|
||||
export const formatDay = (day, locale) =>
|
||||
new Date(`${day}T12:00:00`).toLocaleDateString(locale, {day: 'numeric', month: 'short'});
|
||||
|
||||
// 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`;
|
||||
|
||||
|
||||
+5
-14
@@ -1,4 +1,5 @@
|
||||
import {apiFetch} from './api.js';
|
||||
import {storage} from './storage.js';
|
||||
|
||||
export const LANGUAGES = ['en', 'de'];
|
||||
|
||||
@@ -6,23 +7,13 @@ const LANGUAGE_STORAGE_KEY = 'tbp_language';
|
||||
export const REQUEST_TIMEOUT_MS = 10000;
|
||||
|
||||
export const readCachedLanguage = () => {
|
||||
try {
|
||||
const stored = localStorage.getItem(LANGUAGE_STORAGE_KEY);
|
||||
return LANGUAGES.includes(stored) ? stored : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const stored = storage.get(LANGUAGE_STORAGE_KEY);
|
||||
return LANGUAGES.includes(stored) ? stored : null;
|
||||
};
|
||||
|
||||
export const cacheLanguage = (language) => {
|
||||
try {
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, language);
|
||||
} catch {
|
||||
// Private mode or a full storage: the language is only not cached.
|
||||
}
|
||||
};
|
||||
export const cacheLanguage = (language) => storage.set(LANGUAGE_STORAGE_KEY, language);
|
||||
|
||||
export const ME_URL = () => `${import.meta.env.VITE_API_URL}/api/users/me?v=1`;
|
||||
export const ME_URL = () => `${import.meta.env.VITE_API_URL}/api/users/me`;
|
||||
|
||||
/**
|
||||
* GET /api/users/me on Auth. The caller handles the response status.
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// localStorage throws when site data is blocked (and setItem when it is full). Then nothing is stored and a read is null.
|
||||
export const storage = {
|
||||
get(key) {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
set(key, value) {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
// not stored
|
||||
}
|
||||
},
|
||||
remove(key) {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
// nothing to remove from a blocked storage
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
import {storage} from './storage.js';
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
const blockStorage = () => ['getItem', 'setItem', 'removeItem'].forEach((method) =>
|
||||
vi.spyOn(Storage.prototype, method).mockImplementation(() => {
|
||||
throw new DOMException('blocked', 'SecurityError');
|
||||
}));
|
||||
|
||||
describe('storage', () => {
|
||||
it('reads, writes and removes a value', () => {
|
||||
storage.set('key', 'value');
|
||||
expect(storage.get('key')).toBe('value');
|
||||
|
||||
storage.remove('key');
|
||||
expect(storage.get('key')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not throw when localStorage throws, and reads null', () => {
|
||||
blockStorage();
|
||||
|
||||
expect(() => storage.set('key', 'value')).not.toThrow();
|
||||
expect(() => storage.remove('key')).not.toThrow();
|
||||
expect(storage.get('key')).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user