feat(tiles): let users arrange the dashboard and profile tiles
Build, Test & Push Frontend / quality-check (push) Successful in 18s
Build, Test & Push Frontend / docker (push) Successful in 28s

This commit is contained in:
lobo committed 2026-09-28 15:03:16 +02:00
1 parent b55fcfec92
commit aa9e83e0cb
24 files changed
+792 -426

No files matched your search

-7
View File
@@ -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,
});
/**
+3 -8
View File
@@ -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});
});
});
-15
View File
@@ -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
*/
+91
View File
@@ -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}),
});
+27
View File
@@ -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']);
});
});