diff --git a/src/pages/SettingsPage.test.jsx b/src/pages/SettingsPage.test.jsx
index 7482e49..798d765 100644
--- a/src/pages/SettingsPage.test.jsx
+++ b/src/pages/SettingsPage.test.jsx
@@ -57,80 +57,27 @@ describe('SettingsPage public profile', () => {
await waitFor(() => expect(screen.queryByText(/\/u\/Lobo$/)).toBeNull());
});
- it('saves the stat groups the public profile shows, in a fixed order', async () => {
- renderPage(profile({settings: {useDarkTheme: true, language: 'de', publicProfile: true, profileStats: ['TREND']}}));
-
- fireEvent.click(await screen.findByRole('switch', {name: 'STATS.PILE.TITLE'}));
-
- await waitFor(() => expect(puts()).toHaveLength(1));
- expect(puts()[0]).toEqual({groups: ['PILE', 'TREND']});
- const [url] = fetch.mock.calls.find(([, init]) => init?.method === 'PUT');
- expect(url).toMatch(/\/api\/users\/settings\/profile-stats$/);
- await waitFor(() => expect(screen.getByRole('switch', {name: 'STATS.PILE.TITLE'}).getAttribute('aria-checked'))
- .toBe('true'));
- });
-
- it('waits for a save before the next switch click', async () => {
- const answers = [];
- const me = profile({settings: {useDarkTheme: true, language: 'de', publicProfile: true, profileStats: []}});
- globalThis.fetch = vi.fn((url, init) => (init?.method === 'PUT'
- ? new Promise((resolve) => answers.push(() => resolve({ok: true, status: 204, json: async () => ({})})))
- : Promise.resolve({ok: true, status: 200, json: async () => me})));
- render(
);
-
- fireEvent.click(await screen.findByRole('switch', {name: 'STATS.PILE.TITLE'}));
- await waitFor(() => expect(screen.getByRole('switch', {name: 'STATS.TASTE.TITLE'}).disabled).toBe(true));
- answers.forEach((answer) => answer());
- await waitFor(() => expect(screen.getByRole('switch', {name: 'STATS.TASTE.TITLE'}).disabled).toBe(false));
- fireEvent.click(screen.getByRole('switch', {name: 'STATS.TASTE.TITLE'}));
-
- await waitFor(() => expect(puts()).toHaveLength(2));
- expect(puts()[1]).toEqual({groups: ['PILE', 'TASTE']});
- });
-
- it('switches the group back when the save fails', async () => {
- const me = profile({settings: {useDarkTheme: true, language: 'de', publicProfile: true, profileStats: []}});
- globalThis.fetch = vi.fn(async (url, init) => (init?.method === 'PUT'
- ? {ok: false, status: 500, json: async () => ({})}
- : {ok: true, status: 200, json: async () => me}));
- render(
);
-
- fireEvent.click(await screen.findByRole('switch', {name: 'STATS.PILE.TITLE'}));
-
- await waitFor(() => expect(screen.getByRole('switch', {name: 'STATS.PILE.TITLE'}).getAttribute('aria-checked'))
- .toBe('false'));
- });
-
const styled = (profileStyle) => profile({settings: {useDarkTheme: true, language: 'de', publicProfile: true, profileStyle}});
const styleGroup = (label) => within(screen.getAllByRole('group', {name: label})[1]);
const stylePuts = () => fetch.mock.calls.filter(([url, init]) => init?.method === 'PUT' && url.endsWith('/profile-style'))
.map(([, init]) => JSON.parse(init.body));
it('saves the profile accent apart from the app accent', async () => {
- renderPage(styled({accent: 'blue', corners: 'sharp', bannerAppid: 620, backgroundAppid: null,
- sections: ['PLAYING', 'SUMMARY']}));
+ renderPage(styled({accent: 'blue', corners: 'sharp', bannerAppid: 620, backgroundAppid: null}));
await screen.findByText('SETTINGS.PROFILE_STYLE');
fireEvent.click(styleGroup('SETTINGS.ACCENT').getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.RED'}));
await waitFor(() => expect(stylePuts()).toEqual([{accent: 'red', corners: 'sharp', bannerAppid: 620,
- backgroundAppid: null, sections: ['PLAYING', 'SUMMARY']}]));
+ backgroundAppid: null}]));
expect(styleGroup('SETTINGS.ACCENT').getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.RED'})
.getAttribute('aria-pressed')).toBe('true');
});
- it('moves, hides and shows profile sections', async () => {
- renderPage(styled({sections: ['PLAYING', 'SUMMARY']}));
+ it('points to the profile page for the tiles', async () => {
+ renderPage(profile());
- await screen.findByText('SETTINGS.PROFILE_STYLE');
- // The first row is the first shown section, PLAYING.
- fireEvent.click(screen.getAllByRole('button', {name: 'SETTINGS.PROFILE_SECTION_DOWN'})[0]);
- await waitFor(() => expect(stylePuts()).toHaveLength(1));
- expect(stylePuts()[0].sections).toEqual(['SUMMARY', 'PLAYING']);
-
- fireEvent.click(screen.getByRole('switch', {name: 'SETTINGS.PROFILE_SECTION.STATS'}));
- await waitFor(() => expect(stylePuts()).toHaveLength(2));
- expect(stylePuts()[1].sections).toEqual(['SUMMARY', 'PLAYING', 'STATS']);
+ expect(await screen.findByText('SETTINGS.PROFILE_TILES_HINT')).toBeTruthy();
});
it('removes the profile banner', async () => {
@@ -145,17 +92,11 @@ describe('SettingsPage public profile', () => {
it('offers the profile look only while the profile is public', async () => {
renderPage(profile({settings: {useDarkTheme: true, language: 'de', publicProfile: false}}));
- expect((await screen.findByRole('switch', {name: 'SETTINGS.PROFILE_SECTION.STATS'})).disabled).toBe(true);
+ await screen.findByText('SETTINGS.PROFILE_STYLE');
+ expect(screen.queryByText('SETTINGS.PROFILE_TILES_HINT')).toBeNull();
expect(styleGroup('SETTINGS.ACCENT').getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.RED'}).disabled).toBe(true);
});
- it('offers the stat groups only while the profile is public', async () => {
- renderPage(profile({settings: {useDarkTheme: true, language: 'de', publicProfile: false}}));
-
- expect((await screen.findByRole('switch', {name: 'STATS.PILE.TITLE'})).disabled).toBe(true);
- expect(screen.getByText('SETTINGS.PROFILE_STATS_NEEDS_PUBLIC')).toBeTruthy();
- });
-
it('needs a display name first', async () => {
renderPage(profile({displayName: null, settings: {useDarkTheme: true, language: 'de', publicProfile: false}}));
diff --git a/src/utils/profileStyle.js b/src/utils/profileStyle.js
index e4c3517..1ab475d 100644
--- a/src/utils/profileStyle.js
+++ b/src/utils/profileStyle.js
@@ -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,
});
/**
diff --git a/src/utils/profileStyle.test.js b/src/utils/profileStyle.test.js
index 2c75efb..9c228a3 100644
--- a/src/utils/profileStyle.test.js
+++ b/src/utils/profileStyle.test.js
@@ -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});
});
});
diff --git a/src/utils/stats.js b/src/utils/stats.js
index 5c65908..8903231 100644
--- a/src/utils/stats.js
+++ b/src/utils/stats.js
@@ -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
*/
diff --git a/src/utils/tiles.js b/src/utils/tiles.js
new file mode 100644
index 0000000..79ea521
--- /dev/null
+++ b/src/utils/tiles.js
@@ -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}),
+ });
diff --git a/src/utils/tiles.test.js b/src/utils/tiles.test.js
new file mode 100644
index 0000000..d9e3cc3
--- /dev/null
+++ b/src/utils/tiles.test.js
@@ -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']);
+ });
+});