Public Access
feat(tiles): let users arrange the dashboard and profile tiles
This commit is contained in:
1 parent
b55fcfec92
commit
aa9e83e0cb
24 files changed
+792
-426
No files matched your search
@@ -1,12 +1,8 @@
|
||||
import {apiFetch} from './api.js';
|
||||
import {ACCENTS, CORNERS, DEFAULT_APPEARANCE, appid} from './appearance.js';
|
||||
|
||||
// Auth accepts only these names (ProfileStyleRequest); Core sends no data for a section that is not listed.
|
||||
export const PROFILE_SECTIONS = ['SUMMARY', 'STATUS', 'PLAYING', 'FINISHED', 'STATS'];
|
||||
|
||||
export const DEFAULT_PROFILE_STYLE = {
|
||||
accent: DEFAULT_APPEARANCE.accent, corners: DEFAULT_APPEARANCE.corners, bannerAppid: null, backgroundAppid: null,
|
||||
sections: PROFILE_SECTIONS,
|
||||
};
|
||||
|
||||
export const normalizeProfileStyle = (value) => ({
|
||||
@@ -14,9 +10,6 @@ export const normalizeProfileStyle = (value) => ({
|
||||
corners: CORNERS.includes(value?.corners) ? value.corners : DEFAULT_PROFILE_STYLE.corners,
|
||||
bannerAppid: appid(value?.bannerAppid),
|
||||
backgroundAppid: appid(value?.backgroundAppid),
|
||||
sections: Array.isArray(value?.sections)
|
||||
? value.sections.filter((s, i) => PROFILE_SECTIONS.includes(s) && value.sections.indexOf(s) === i)
|
||||
: PROFILE_SECTIONS,
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,13 +6,8 @@ describe('normalizeProfileStyle', () => {
|
||||
expect(normalizeProfileStyle(undefined)).toEqual(DEFAULT_PROFILE_STYLE);
|
||||
});
|
||||
|
||||
it('keeps the order, drops unknown and repeated sections and bad values', () => {
|
||||
expect(normalizeProfileStyle({accent: 'hotpink', corners: 'round', bannerAppid: -5, backgroundAppid: 620,
|
||||
sections: ['STATS', 'NOTES', 'PLAYING', 'STATS']}))
|
||||
.toEqual({accent: 'violet', corners: 'round', bannerAppid: null, backgroundAppid: 620, sections: ['STATS', 'PLAYING']});
|
||||
});
|
||||
|
||||
it('keeps an empty list: every section hidden', () => {
|
||||
expect(normalizeProfileStyle({sections: []}).sections).toEqual([]);
|
||||
it('drops bad values', () => {
|
||||
expect(normalizeProfileStyle({accent: 'hotpink', corners: 'round', bannerAppid: -5, backgroundAppid: 620}))
|
||||
.toEqual({accent: 'violet', corners: 'round', bannerAppid: null, backgroundAppid: 620});
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,3 @@
|
||||
import {apiFetch} from './api.js';
|
||||
|
||||
// The groups a user can show on the public profile; the overview numbers are always there.
|
||||
export const PROFILE_STAT_GROUPS = ['PILE', 'TREND', 'TOP_GAMES', 'TASTE', 'HABITS', 'FINISHED'];
|
||||
|
||||
/**
|
||||
* PUT /api/users/settings/profile-stats on Auth. The caller handles the response status.
|
||||
*/
|
||||
export const saveProfileStats = (groups) =>
|
||||
apiFetch(`${import.meta.env.VITE_API_URL}/api/users/settings/profile-stats`, {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({groups}),
|
||||
});
|
||||
|
||||
/**
|
||||
* @param groups comma separated stat groups of Core (StatGroup); empty for all
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import {apiFetch} from './api.js';
|
||||
|
||||
/**
|
||||
* The stat group of Core a STATS_ tile shows, e.g. STATS_TOP_GAMES -> TOP_GAMES; null for any other tile.
|
||||
*/
|
||||
export function statGroup(id) {
|
||||
return id.startsWith('STATS_') ? id.slice('STATS_'.length) : null;
|
||||
}
|
||||
|
||||
// Tile id -> its column span per size, in a 12 column grid. A size not listed is not offered. M is the look from
|
||||
// before the layouts. Auth accepts only these ids (DashboardTilesRequest, ProfileTilesRequest).
|
||||
const STAT_TILES = {
|
||||
STATS_PILE: {M: 6, L: 12},
|
||||
STATS_TREND: {M: 6, L: 12},
|
||||
STATS_TOP_GAMES: {M: 6, L: 12},
|
||||
STATS_TASTE: {M: 6, L: 12},
|
||||
STATS_HABITS: {M: 6, L: 12},
|
||||
STATS_FINISHED: {M: 6, L: 12},
|
||||
};
|
||||
|
||||
export const DASHBOARD_TILES = {
|
||||
GAME_COUNT: {S: 2, M: 4, L: 8},
|
||||
MOST_PLAYED: {S: 4, M: 8, L: 12},
|
||||
PLAYING: {S: 3, M: 6, L: 12},
|
||||
PILE_OF_SHAME: {M: 6, L: 12},
|
||||
ACTIVITY: {S: 6, M: 12},
|
||||
ACHIEVEMENTS: {S: 6, M: 12},
|
||||
STATS_OVERVIEW: {M: 12},
|
||||
...STAT_TILES,
|
||||
};
|
||||
|
||||
export const PROFILE_TILES = {
|
||||
SUMMARY: {S: 6, M: 12},
|
||||
STATUS: {S: 6, M: 12},
|
||||
PLAYING: {S: 6, M: 12},
|
||||
FINISHED: {S: 6, M: 12},
|
||||
...STAT_TILES,
|
||||
};
|
||||
|
||||
// The i18n key of each tile's name in the edit mode: the heading of its card where it has one.
|
||||
const STAT_TITLES = Object.fromEntries(Object.keys(STAT_TILES).map((id) => [id, `STATS.${statGroup(id)}.TITLE`]));
|
||||
|
||||
export const DASHBOARD_TITLES = {
|
||||
GAME_COUNT: 'DASHBOARD.STEAM_GAMES', MOST_PLAYED: 'DASHBOARD.MOST_PLAYED', PLAYING: 'DASHBOARD.CURRENTLY_PLAYING',
|
||||
PILE_OF_SHAME: 'DASHBOARD.PILE_OF_SHAME', ACTIVITY: 'DASHBOARD.ACTIVITY', ACHIEVEMENTS: 'DASHBOARD.ACHIEVEMENTS.TITLE',
|
||||
STATS_OVERVIEW: 'STATS.OVERVIEW.TITLE', ...STAT_TITLES,
|
||||
};
|
||||
|
||||
export const PROFILE_TITLES = {
|
||||
SUMMARY: 'TILES.SUMMARY', STATUS: 'TILES.STATUS', PLAYING: 'PROFILE.PLAYING', FINISHED: 'PROFILE.FINISHED_GAMES',
|
||||
...STAT_TITLES,
|
||||
};
|
||||
|
||||
const all = (ids) => ids.map((id) => ({id, size: 'M'}));
|
||||
|
||||
export const DEFAULT_DASHBOARD = all(['GAME_COUNT', 'MOST_PLAYED', 'PLAYING', 'PILE_OF_SHAME', 'ACTIVITY', 'ACHIEVEMENTS']);
|
||||
export const DEFAULT_PROFILE = all(['SUMMARY', 'STATUS', 'PLAYING', 'FINISHED']);
|
||||
|
||||
export const SIZES = ['S', 'M', 'L'];
|
||||
|
||||
// Written out, so Tailwind finds every class.
|
||||
const SPANS = {
|
||||
2: 'md:col-span-2', 3: 'md:col-span-3', 4: 'md:col-span-4', 6: 'md:col-span-6', 8: 'md:col-span-8',
|
||||
12: 'md:col-span-12',
|
||||
};
|
||||
|
||||
export const spanClass = (tiles, {id, size}) => SPANS[tiles[id][size]];
|
||||
|
||||
/**
|
||||
* A saved layout as the page can use it: unknown and repeated ids are left out, a size the tile does not offer
|
||||
* becomes M. A saved layout of a tile removed later so breaks nothing.
|
||||
*
|
||||
* @param saved list of {id, size}; null or not a list gives the fallback
|
||||
*/
|
||||
export const normalizeLayout = (saved, tiles, fallback) => {
|
||||
if (!Array.isArray(saved)) return fallback;
|
||||
const seen = new Set();
|
||||
return saved.filter((tile) => tiles[tile?.id] && !seen.has(tile.id) && seen.add(tile.id))
|
||||
.map(({id, size}) => ({id, size: tiles[id][size] ? size : 'M'}));
|
||||
};
|
||||
|
||||
/**
|
||||
* @param path dashboard-tiles or profile-tiles
|
||||
* @param layout null resets the dashboard to its default
|
||||
*/
|
||||
export const saveTiles = (path, layout) =>
|
||||
apiFetch(`${import.meta.env.VITE_API_URL}/api/users/settings/${path}`, {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({tiles: layout}),
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {DASHBOARD_TILES, DEFAULT_DASHBOARD, normalizeLayout, spanClass} from './tiles.js';
|
||||
|
||||
describe('normalizeLayout', () => {
|
||||
it('is the fallback without a saved list', () => {
|
||||
expect(normalizeLayout(null, DASHBOARD_TILES, DEFAULT_DASHBOARD)).toBe(DEFAULT_DASHBOARD);
|
||||
expect(normalizeLayout(undefined, DASHBOARD_TILES, DEFAULT_DASHBOARD)).toBe(DEFAULT_DASHBOARD);
|
||||
});
|
||||
|
||||
it('drops unknown and repeated tiles and makes a size the tile does not offer M', () => {
|
||||
expect(normalizeLayout([
|
||||
{id: 'STATS_TREND', size: 'L'}, {id: 'WISHLIST', size: 'M'}, {id: 'PILE_OF_SHAME', size: 'S'},
|
||||
{id: 'STATS_TREND', size: 'M'}, null,
|
||||
], DASHBOARD_TILES, DEFAULT_DASHBOARD)).toEqual([{id: 'STATS_TREND', size: 'L'}, {id: 'PILE_OF_SHAME', size: 'M'}]);
|
||||
});
|
||||
|
||||
it('keeps an empty list: every tile hidden', () => {
|
||||
expect(normalizeLayout([], DASHBOARD_TILES, DEFAULT_DASHBOARD)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('spanClass', () => {
|
||||
it('halves and doubles the normal width', () => {
|
||||
expect(['S', 'M', 'L'].map((size) => spanClass(DASHBOARD_TILES, {id: 'MOST_PLAYED', size})))
|
||||
.toEqual(['md:col-span-4', 'md:col-span-8', 'md:col-span-12']);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user