From aa9e83e0cb00977e680567aaf184d9b91b4122ae Mon Sep 17 00:00:00 2001 From: lobo Date: Mon, 28 Sep 2026 15:03:16 +0200 Subject: [PATCH] feat(tiles): let users arrange the dashboard and profile tiles --- README.md | 10 +- public/i18n/de.json | 34 +-- public/i18n/en.json | 34 +-- src/components/CurrentlyPlayingCard.jsx | 8 +- src/components/ProfileStyleSettings.jsx | 47 +--- src/components/TileGrid.jsx | 126 ++++++++++ src/components/TileGrid.test.jsx | 70 ++++++ src/components/stats/ProfileStatsSettings.jsx | 40 --- src/components/stats/statGroupTile.jsx | 27 ++ src/hooks/useCurrentUser.js | 6 +- src/hooks/useSaveProfileStyle.js | 32 +-- src/hooks/useSaveResponse.js | 32 +++ src/pages/Dashboard.jsx | 233 ++++++++++-------- src/pages/Dashboard.test.jsx | 61 ++++- src/pages/GameDetail.test.jsx | 16 +- src/pages/PublicProfilePage.jsx | 90 ++++--- src/pages/PublicProfilePage.test.jsx | 93 ++++++- src/pages/SettingsPage.jsx | 35 +-- src/pages/SettingsPage.test.jsx | 73 +----- src/utils/profileStyle.js | 7 - src/utils/profileStyle.test.js | 11 +- src/utils/stats.js | 15 -- src/utils/tiles.js | 91 +++++++ src/utils/tiles.test.js | 27 ++ 24 files changed, 792 insertions(+), 426 deletions(-) create mode 100644 src/components/TileGrid.jsx create mode 100644 src/components/TileGrid.test.jsx delete mode 100644 src/components/stats/ProfileStatsSettings.jsx create mode 100644 src/components/stats/statGroupTile.jsx create mode 100644 src/hooks/useSaveResponse.js create mode 100644 src/utils/tiles.js create mode 100644 src/utils/tiles.test.js diff --git a/README.md b/README.md index 4c18376..81eb4fb 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,9 @@ To Be Played is split into three repositories: ## Features -- **Dashboard**: most played games, games in progress, "Pile of Shame" with backlog hours, its trend of the last 90 +- **Dashboard** with an edit mode ("Customize"): the user orders, hides and resizes the tiles (small = half width + and less content, large = double width and more), and can add the stat groups of "Your stats" as tiles. Stored + in Auth, so it follows the user to the phone; "Reset" goes back to the default. Tiles: most played games, games in progress, "Pile of Shame" with backlog hours, its trend of the last 90 days ("this month: +N added, -N off the pile") and a random suggestion, Steam avatar and a banner from the most played game of the last two weeks, and an achievement card: unlocked total, perfect games, average completion and the 5 games closest to 100 %. @@ -50,9 +52,9 @@ To Be Played is split into three repositories: the proved Steam ID, never a typed-in one. A Steam ID typed in before shows as "not verified" with a button to verify it; unlinking is possible. - **Public profile** at `/u/`, opt-in in the settings, with perfect games and average completion. - The user picks in the settings which stat groups of "Your stats" it shows (none by default), and designs its - look: accent, corners, banner and background from own game art ("Profile art" on a game's page), and which - sections it shows in which order. Visitors see the owner's look on that page only; light or dark mode stays theirs. + The owner arranges it with the same edit mode on the profile page itself (the tiles, incl. the stat groups of + "Your stats"), and sets its look in the settings: accent, corners, banner and background from own game art + ("Profile art" on a game's page). Visitors see the owner's look on that page only; light or dark mode stays theirs. - **Appearance**: accent color, corner style, wide layout, page background and banner from your own game art. Saved in Auth, so it follows you to every device. - **Broken pictures**: a Steam cover or achievement icon that does not load shows a placeholder and is reported diff --git a/public/i18n/de.json b/public/i18n/de.json index 2e26457..6d337be 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -35,25 +35,12 @@ "ERROR_TOO_MANY_ATTEMPTS": "Zu viele fehlgeschlagene Versuche. Bitte in {{minutes}} Minuten erneut versuchen." }, "SETTINGS": { - "PROFILE_STATS": "Statistik im öffentlichen Profil", - "PROFILE_STATS_HINT": "Wähle die Statistiken, die Besucher sehen. Sie erscheinen, wenn der Abschnitt Statistiken unten an ist.", - "PROFILE_STATS_NEEDS_PUBLIC": "Mach dein Profil zuerst öffentlich.", "PROFILE_STYLE": "Aussehen des Profils", "PROFILE_STYLE_HINT": "So sehen Besucher dein öffentliches Profil. Das Aussehen deiner App bleibt, wie es ist.", "PROFILE_BANNER": "Banner", "PROFILE_BACKGROUND": "Hintergrund", "PROFILE_ART_HINT": "Wähle das Bild auf der Seite eines Spiels: \"Profilbild\".", "PROFILE_ART_REMOVE": "Entfernen", - "PROFILE_SECTIONS": "Abschnitte und Reihenfolge", - "PROFILE_SECTION": { - "SUMMARY": "Summen", - "STATUS": "Statusleiste", - "PLAYING": "Spielt gerade", - "FINISHED": "Beendete Spiele", - "STATS": "Statistiken" - }, - "PROFILE_SECTION_UP": "{{name}} nach oben", - "PROFILE_SECTION_DOWN": "{{name}} nach unten", "TITLE": "Einstellungen", "LOADING": "Einstellungen werden geladen …", "ACCOUNT": "Konto", @@ -137,7 +124,8 @@ "WRONG_PASSWORD": "Das Passwort ist falsch.", "TOO_MANY": "Zu viele falsche Passwörter. Bitte in {{minutes}} Minuten erneut versuchen.", "ERROR": "Das Konto konnte nicht gelöscht werden. Bitte später erneut versuchen." - } + }, + "PROFILE_TILES_HINT": "Wähle, was dein Profil zeigt und wo: öffne dein Profil und klicke auf \"Anpassen\"." }, "VALIDATION": { "EMAIL_REQUIRED": "Email ist erforderlich.", @@ -615,5 +603,23 @@ "RANGE": "{{start}} bis {{end}}", "DONE_COUNT_one": "Von {{count}} Nutzer für immer geschlossen", "DONE_COUNT_other": "Von {{count}} Nutzern für immer geschlossen" + }, + "TILES": { + "CUSTOMIZE": "Anpassen", + "EDIT_HINT": "Verschiebe, vergrößere oder verstecke die Kacheln. Gespeichert wird erst mit \"Fertig\".", + "DONE": "Fertig", + "CANCEL": "Abbrechen", + "RESET": "Zurücksetzen", + "HIDDEN": "Versteckte Kacheln", + "NO_DATA": "Noch nichts zu zeigen.", + "MOVE_UP": "{{name}} nach oben", + "MOVE_DOWN": "{{name}} nach unten", + "SIZE": "Größe von {{name}}", + "HIDE": "{{name}} verstecken", + "SIZE_S": "Klein", + "SIZE_M": "Normal", + "SIZE_L": "Groß", + "SUMMARY": "Summen", + "STATUS": "Statusleiste" } } diff --git a/public/i18n/en.json b/public/i18n/en.json index c2a32d4..9ecfa2a 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -35,25 +35,12 @@ "ERROR_TOO_MANY_ATTEMPTS": "Too many failed attempts. Please try again in {{minutes}} minutes." }, "SETTINGS": { - "PROFILE_STATS": "Stats on the public profile", - "PROFILE_STATS_HINT": "Pick the stat groups visitors see. They show when the Stats section below is on.", - "PROFILE_STATS_NEEDS_PUBLIC": "Make your profile public first.", "PROFILE_STYLE": "Profile look", "PROFILE_STYLE_HINT": "How visitors see your public profile. Your own app look stays as it is.", "PROFILE_BANNER": "Banner", "PROFILE_BACKGROUND": "Background", "PROFILE_ART_HINT": "Pick the art on a game's page: \"Profile art\".", "PROFILE_ART_REMOVE": "Remove", - "PROFILE_SECTIONS": "Sections and their order", - "PROFILE_SECTION": { - "SUMMARY": "Totals", - "STATUS": "Status bar", - "PLAYING": "Playing", - "FINISHED": "Finished games", - "STATS": "Stats" - }, - "PROFILE_SECTION_UP": "Move {{name}} up", - "PROFILE_SECTION_DOWN": "Move {{name}} down", "TITLE": "Settings", "LOADING": "Loading settings …", "ACCOUNT": "Account", @@ -137,7 +124,8 @@ "WRONG_PASSWORD": "The password is wrong.", "TOO_MANY": "Too many wrong passwords. Please try again in {{minutes}} minutes.", "ERROR": "The account could not be deleted. Please try again later." - } + }, + "PROFILE_TILES_HINT": "Choose what your profile shows, and where: open your profile and click \"Customize\"." }, "VALIDATION": { "EMAIL_REQUIRED": "Email is required.", @@ -615,5 +603,23 @@ "RANGE": "{{start}} to {{end}}", "DONE_COUNT_one": "Closed for good by {{count}} user", "DONE_COUNT_other": "Closed for good by {{count}} users" + }, + "TILES": { + "CUSTOMIZE": "Customize", + "EDIT_HINT": "Move, resize or hide the tiles. Nothing is saved before \"Done\".", + "DONE": "Done", + "CANCEL": "Cancel", + "RESET": "Reset", + "HIDDEN": "Hidden tiles", + "NO_DATA": "Nothing to show yet.", + "MOVE_UP": "Move {{name}} up", + "MOVE_DOWN": "Move {{name}} down", + "SIZE": "Size of {{name}}", + "HIDE": "Hide {{name}}", + "SIZE_S": "Small", + "SIZE_M": "Normal", + "SIZE_L": "Large", + "SUMMARY": "Totals", + "STATUS": "Status bar" } } diff --git a/src/components/CurrentlyPlayingCard.jsx b/src/components/CurrentlyPlayingCard.jsx index 86aef97..f294b58 100644 --- a/src/components/CurrentlyPlayingCard.jsx +++ b/src/components/CurrentlyPlayingCard.jsx @@ -4,10 +4,14 @@ import {ArrowRight, Gamepad2} from 'lucide-react'; import {Button} from '@/components/ui/button'; import {Card} from '@/components/ui/card'; +import {cn} from '@/lib/utils'; import GameCover from './GameCover.jsx'; import {formatPlaytime} from '../utils/games.js'; -export default function CurrentlyPlayingCard({games, syncing}) { +/** + * @param wide four games per row instead of two, for the large tile + */ +export default function CurrentlyPlayingCard({games, syncing, wide}) { const {t} = useTranslation(); return ( @@ -26,7 +30,7 @@ export default function CurrentlyPlayingCard({games, syncing}) { {syncing ? t('LIBRARY.SYNCING_FIRST_TIME') : t('DASHBOARD.NOTHING_PLAYING')}

) : ( -
    +
      {games.map((g) => (
    • diff --git a/src/components/ProfileStyleSettings.jsx b/src/components/ProfileStyleSettings.jsx index d904d8f..9b1bfcf 100644 --- a/src/components/ProfileStyleSettings.jsx +++ b/src/components/ProfileStyleSettings.jsx @@ -1,11 +1,9 @@ import {useTranslation} from 'react-i18next'; -import {ArrowDown, ArrowUp, ImageOff} from 'lucide-react'; +import {ImageOff} from 'lucide-react'; import {Button} from '@/components/ui/button'; import {Label} from '@/components/ui/label'; -import {Switch} from '@/components/ui/switch'; import {AccentPicker, CornersPicker} from './StylePickers.jsx'; -import {PROFILE_SECTIONS} from '../utils/profileStyle.js'; import {coverUrl} from '../utils/games.js'; function ArtRow({id, label, appid, onRemove, disabled}) { @@ -31,24 +29,14 @@ function ArtRow({id, label, appid, onRemove, disabled}) { } /** - * The look of the public profile: accent, corners, banner, background and the sections in their order. + * The look of the public profile: accent, corners, banner and background. The tiles are arranged on the profile + * page itself. * * @param disabled the profile is private, or a save runs * @param onChange gets the changed fields only */ export default function ProfileStyleSettings({style, disabled, onChange}) { const {t} = useTranslation(); - const shown = style.sections; - const rows = [...shown, ...PROFILE_SECTIONS.filter((s) => !shown.includes(s))]; - const move = (index, by) => { - const next = [...shown]; - [next[index], next[index + by]] = [next[index + by], next[index]]; - onChange({sections: next}); - }; - const toggle = (section) => onChange({ - sections: shown.includes(section) ? shown.filter((s) => s !== section) : [...shown, section], - }); - return (
      @@ -63,35 +51,6 @@ export default function ProfileStyleSettings({style, disabled, onChange}) { disabled={disabled} onRemove={() => onChange({bannerAppid: null})}/> onChange({backgroundAppid: null})}/> -
      - {t('SETTINGS.PROFILE_SECTIONS')} -
        - {rows.map((section) => { - const index = shown.indexOf(section); - const name = t(`SETTINGS.PROFILE_SECTION.${section}`); - return ( -
      • - -
        - - - = 0} disabled={disabled} - className="ml-2" onCheckedChange={() => toggle(section)}/> -
        -
      • - ); - })} -
      -
      ); } diff --git a/src/components/TileGrid.jsx b/src/components/TileGrid.jsx new file mode 100644 index 0000000..9e9999a --- /dev/null +++ b/src/components/TileGrid.jsx @@ -0,0 +1,126 @@ +import {useState} from 'react'; +import {useTranslation} from 'react-i18next'; +import {ArrowDown, ArrowUp, Check, EyeOff, Loader2, Plus, RotateCcw, X} from 'lucide-react'; + +import {Button} from '@/components/ui/button'; +import {Card} from '@/components/ui/card'; +import {cn} from '@/lib/utils'; +import {SIZES, spanClass} from '../utils/tiles.js'; + +/** + * The tiles of a page in their order, in a 12 column grid (one column on a phone). In edit mode each tile gets + * move, size and hide buttons, and the hidden tiles can be added again; nothing is saved before "Done". + * + * @param tiles the tile table (utils/tiles.js) + * @param titles tile id -> i18n key of its name in the edit mode + * @param renderTile (id, size) => the tile, or null when it has nothing to show (then it is left out, and in edit + * mode it shows a placeholder so it can still be moved) + * @param defaultLayout offered as "Reset"; none: no reset + * @param onSave (layout) => promise; the page ends the edit mode when the save worked + */ +export default function TileGrid({tiles, titles, layout, renderTile, editing, defaultLayout, onSave, onCancel}) { + if (!editing) { + return ( +
      + {layout.map((tile) => { + const content = renderTile(tile.id, tile.size); + return content && ( + // *:h-full: the card fills the grid row, as it did as a direct grid item. +
      {content}
      + ); + })} +
      + ); + } + + return ; +} + +// Mounted per edit session, so every session starts from the saved layout. +function TileEditor({tiles, titles, layout, renderTile, defaultLayout, onSave, onCancel}) { + const {t} = useTranslation(); + const [shown, setShown] = useState(layout); + const [saving, setSaving] = useState(false); + const hidden = Object.keys(tiles).filter((id) => !shown.some((tile) => tile.id === id)); + const move = (index, by) => { + const next = [...shown]; + [next[index], next[index + by]] = [next[index + by], next[index]]; + setShown(next); + }; + const save = async () => { + setSaving(true); + await onSave(shown); + setSaving(false); + }; + + return ( +
      +
      +

      {t('TILES.EDIT_HINT')}

      +
      + {defaultLayout && ( + + )} + + +
      +
      + +
      + {shown.map((tile, index) => { + const name = t(titles[tile.id]); + const sizes = SIZES.filter((size) => tiles[tile.id][size]); + return ( +
      +
      + {name} + + + {sizes.length > 1 && ( +
      + {sizes.map((size) => ( + + ))} +
      + )} + +
      + {/* inert: a click on a game in the tile must not leave the edit mode. */} +
      + {renderTile(tile.id, tile.size) || ( + {t('TILES.NO_DATA')} + )} +
      +
      + ); + })} +
      + + {hidden.length > 0 && ( +
      +

      {t('TILES.HIDDEN')}

      +
      + {hidden.map((id) => ( + + ))} +
      +
      + )} +
      + ); +} diff --git a/src/components/TileGrid.test.jsx b/src/components/TileGrid.test.jsx new file mode 100644 index 0000000..808747c --- /dev/null +++ b/src/components/TileGrid.test.jsx @@ -0,0 +1,70 @@ +// @vitest-environment jsdom +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; +import TileGrid from './TileGrid.jsx'; + +vi.mock('react-i18next', () => import('../test/i18n-mock.js')); + +const TILES = {A: {S: 3, M: 6, L: 12}, B: {M: 6}, C: {M: 12}}; +const TITLES = {A: 'TILES.A', B: 'TILES.B', C: 'TILES.C'}; +const LAYOUT = [{id: 'A', size: 'M'}, {id: 'B', size: 'M'}]; +const renderTile = (id, size) => (id === 'C' ? null :

      {`tile ${id} ${size}`}

      ); + +const renderGrid = (props) => render( + , +); +const order = (container) => [...container.querySelectorAll('[data-tile]')].map((el) => el.dataset.tile); + +describe('TileGrid', () => { + afterEach(cleanup); + + it('shows the tiles in order with their spans and leaves out a tile without content', () => { + const {container} = renderGrid({layout: [...LAYOUT, {id: 'C', size: 'M'}]}); + + expect(order(container)).toEqual(['A', 'B']); + expect(container.querySelector('[data-tile="A"]').className).toContain('md:col-span-6'); + }); + + it('moves, resizes, hides and adds tiles, and saves only on "Done"', async () => { + const onSave = vi.fn(async () => true); + const {container} = renderGrid({editing: true, onSave}); + + fireEvent.click(screen.getAllByRole('button', {name: 'TILES.MOVE_DOWN'})[0]); + expect(order(container)).toEqual(['B', 'A']); + fireEvent.click(screen.getByRole('button', {name: 'TILES.SIZE_L'})); + fireEvent.click(screen.getAllByRole('button', {name: 'TILES.HIDE'})[0]); + fireEvent.click(screen.getByRole('button', {name: 'TILES.C'})); + expect(onSave).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', {name: 'TILES.DONE'})); + + await waitFor(() => expect(onSave).toHaveBeenCalledWith([{id: 'A', size: 'L'}, {id: 'C', size: 'M'}])); + }); + + it('offers sizes only for a tile with more than one, and shows a placeholder for a tile without content', () => { + renderGrid({editing: true, layout: [{id: 'B', size: 'M'}, {id: 'C', size: 'M'}]}); + + expect(screen.queryByRole('button', {name: 'TILES.SIZE_S'})).toBeNull(); + expect(screen.getByText('TILES.NO_DATA')).toBeTruthy(); + }); + + it('resets to the default layout and cancels without a save', () => { + const onSave = vi.fn(); + const onCancel = vi.fn(); + const {container} = renderGrid({editing: true, defaultLayout: [{id: 'C', size: 'M'}], onSave, onCancel}); + + fireEvent.click(screen.getByRole('button', {name: 'TILES.RESET'})); + expect(order(container)).toEqual(['C']); + fireEvent.click(screen.getByRole('button', {name: 'TILES.CANCEL'})); + + expect(onCancel).toHaveBeenCalled(); + expect(onSave).not.toHaveBeenCalled(); + }); + + it('offers no reset without a default layout', () => { + renderGrid({editing: true}); + + expect(screen.queryByRole('button', {name: 'TILES.RESET'})).toBeNull(); + }); +}); diff --git a/src/components/stats/ProfileStatsSettings.jsx b/src/components/stats/ProfileStatsSettings.jsx deleted file mode 100644 index 1d5ffd4..0000000 --- a/src/components/stats/ProfileStatsSettings.jsx +++ /dev/null @@ -1,40 +0,0 @@ -import {useTranslation} from 'react-i18next'; - -import {Label} from '@/components/ui/label'; -import {Switch} from '@/components/ui/switch'; -import {PROFILE_STAT_GROUPS} from '../../utils/stats.js'; - -/** - * One switch per stat group the public profile can show. - * - * @param disabled the profile is private - * @param saving a save runs; the switches wait for it - * @param onChange gets the whole new list of groups - */ -export default function ProfileStatsSettings({selected, disabled, saving, onChange}) { - const {t} = useTranslation(); - const toggle = (group) => onChange(selected.includes(group) - ? selected.filter((g) => g !== group) - : PROFILE_STAT_GROUPS.filter((g) => g === group || selected.includes(g))); - - return ( -
      -
      - {t('SETTINGS.PROFILE_STATS')} -

      - {disabled ? t('SETTINGS.PROFILE_STATS_NEEDS_PUBLIC') : t('SETTINGS.PROFILE_STATS_HINT')} -

      -
      -
        - {PROFILE_STAT_GROUPS.map((group) => ( -
      • - - toggle(group)}/> -
      • - ))} -
      -
      - ); -} diff --git a/src/components/stats/statGroupTile.jsx b/src/components/stats/statGroupTile.jsx new file mode 100644 index 0000000..08a52f6 --- /dev/null +++ b/src/components/stats/statGroupTile.jsx @@ -0,0 +1,27 @@ +import OverviewStats from './OverviewStats.jsx'; +import PileStats from './PileStats.jsx'; +import TrendStats from './TrendStats.jsx'; +import TopGamesStats from './TopGamesStats.jsx'; +import TasteStats from './TasteStats.jsx'; +import HabitsStats from './HabitsStats.jsx'; +import FinishedStats from './FinishedStats.jsx'; + +/** + * One stat group as a tile; null while its data is not there. A function, not a component: a tile grid leaves out a + * null tile, and an element is never null. + * + * @param stats the answer of GET /api/library/stats, or the stats of a public profile + * @param linked the game rows link to the game pages; false on the public profile + */ +export default function statGroupTile(id, stats, linked = true) { + switch (id) { + case 'STATS_OVERVIEW': return stats?.overview ? : null; + case 'STATS_PILE': return stats?.pile ? : null; + case 'STATS_TREND': return stats?.trend ? : null; + case 'STATS_TOP_GAMES': return stats?.topGames ? : null; + case 'STATS_TASTE': return stats?.taste ? : null; + case 'STATS_HABITS': return stats?.habits ? : null; + case 'STATS_FINISHED': return stats?.finished ? : null; + default: return null; + } +} diff --git a/src/hooks/useCurrentUser.js b/src/hooks/useCurrentUser.js index 2fa1d5a..e7e1bbb 100644 --- a/src/hooks/useCurrentUser.js +++ b/src/hooks/useCurrentUser.js @@ -4,8 +4,8 @@ import {sessionStamp} from '../utils/auth.js'; import {normalizeProfileStyle} from '../utils/profileStyle.js'; /** - * The stored role, the active role ("view as") and the public profile style of the logged-in user, or null while - * loading and on any error. + * The stored role, the active role ("view as"), the display name and the public profile style of the logged-in + * user, or null while loading and on any error. * Loads again after a new login or a role switch (see sessionStamp) on the next render. */ export default function useCurrentUser(enabled) { @@ -22,7 +22,7 @@ export default function useCurrentUser(enabled) { try { const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`); const data = response.ok ? await response.json() : null; - if (!cancelled) setUser(data && {role: data.role, activeRole: data.activeRole ?? data.role, + if (!cancelled) setUser(data && {role: data.role, activeRole: data.activeRole ?? data.role, displayName: data.displayName, profileStyle: normalizeProfileStyle(data.settings?.profileStyle)}); } catch { if (!cancelled) setUser(null); diff --git a/src/hooks/useSaveProfileStyle.js b/src/hooks/useSaveProfileStyle.js index ae96fe3..2b3ef4e 100644 --- a/src/hooks/useSaveProfileStyle.js +++ b/src/hooks/useSaveProfileStyle.js @@ -1,7 +1,5 @@ import {useCallback} from 'react'; -import {useTranslation} from 'react-i18next'; -import {toast} from 'sonner'; -import useSessionExpired from './useSessionExpired.js'; +import useSaveResponse from './useSaveResponse.js'; import {loadProfileStyle, saveProfileStyle} from '../utils/profileStyle.js'; /** @@ -10,27 +8,15 @@ import {loadProfileStyle, saveProfileStyle} from '../utils/profileStyle.js'; * style. Resolves to the saved style, or null after the error message (or the session end on a 401). */ export default function useSaveProfileStyle() { - const {t} = useTranslation(); - const expireSession = useSessionExpired(); + const run = useSaveResponse(); return useCallback(async (changes, shown) => { - try { + let style = null; + const saved = await run(async () => { const stored = await loadProfileStyle() ?? shown; - const style = {...stored, ...(typeof changes === 'function' ? changes(stored) : changes)}; - const response = await saveProfileStyle(style); - if (response.status === 401) { - expireSession(); - return null; - } - if (!response.ok) { - toast.error(t('SETTINGS.ERROR_SAVE')); - return null; - } - return style; - } catch (err) { - console.error('Profile style save error:', err); - toast.error(t('INFO.ERROR.NETWORK_FAILURE')); - return null; - } - }, [expireSession, t]); + style = {...stored, ...(typeof changes === 'function' ? changes(stored) : changes)}; + return saveProfileStyle(style); + }); + return saved ? style : null; + }, [run]); } diff --git a/src/hooks/useSaveResponse.js b/src/hooks/useSaveResponse.js new file mode 100644 index 0000000..e520c87 --- /dev/null +++ b/src/hooks/useSaveResponse.js @@ -0,0 +1,32 @@ +import {useCallback} from 'react'; +import {useTranslation} from 'react-i18next'; +import {toast} from 'sonner'; +import useSessionExpired from './useSessionExpired.js'; + +/** + * Returns `run(send)` for a settings save: it awaits `send()` (a fetch) and shows the error message, or ends the + * session on a 401. Resolves to true when saved. + */ +export default function useSaveResponse() { + const {t} = useTranslation(); + const expireSession = useSessionExpired(); + + return useCallback(async (send) => { + try { + const response = await send(); + if (response.status === 401) { + expireSession(); + return false; + } + if (!response.ok) { + toast.error(t('SETTINGS.ERROR_SAVE')); + return false; + } + return true; + } catch (err) { + console.error('Settings save error:', err); + toast.error(t('INFO.ERROR.NETWORK_FAILURE')); + return false; + } + }, [expireSession, t]); +} diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index 18c485f..c84f9d9 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -2,7 +2,7 @@ import React, {useCallback, useEffect, useState} from 'react'; import {Link, useNavigate} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; import {toast} from 'sonner'; -import {ArrowRight, Hash, Link2, RefreshCw, Trophy} from 'lucide-react'; +import {ArrowRight, Hash, LayoutGrid, Link2, RefreshCw, Trophy} from 'lucide-react'; import {Badge} from '@/components/ui/badge'; import {Button} from '@/components/ui/button'; @@ -25,18 +25,22 @@ import HeroBanner from '../components/HeroBanner.jsx'; import SteamAvatar from '../components/SteamAvatar.jsx'; import useSteamProfile from '../hooks/useSteamProfile.js'; import useUserStats from '../hooks/useUserStats.js'; +import useSaveResponse from '../hooks/useSaveResponse.js'; +import TileGrid from '../components/TileGrid.jsx'; +import statGroupTile from '../components/stats/statGroupTile.jsx'; +import {DASHBOARD_TILES, DASHBOARD_TITLES, DEFAULT_DASHBOARD, normalizeLayout, saveTiles, statGroup} from '../utils/tiles.js'; import {usePreferences} from '../context/PreferencesContext.jsx'; import {apiFetch} from '../utils/api.js'; import {hasSession} from '../utils/auth.js'; -const TOP_GAMES = 5; +// Games per tile size; the page loads the most for the large tile. +const TOP_GAMES = {S: 3, M: 5, L: 10}; +const PLAYING_GAMES = {S: 2, M: 4, L: 8}; // The games endpoint accepts only the page sizes 20, 40 and 80. const TOP_GAMES_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc'; -const PLAYING_GAMES = 4; const PLAYING_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc&status=PLAYING'; const JSON_HEADERS = {'Content-Type': 'application/json'}; -const TREND_GROUPS = ['TREND']; const Dashboard = () => { const {t} = useTranslation(); @@ -53,8 +57,18 @@ const Dashboard = () => { const [shuffling, setShuffling] = useState(false); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - const pileTrend = useUserStats(TREND_GROUPS, expireSession, hasSession()); - const reloadTrend = pileTrend.reload; + const [editing, setEditing] = useState(false); + const runSave = useSaveResponse(); + const layout = normalizeLayout(userData?.settings?.dashboardTiles, DASHBOARD_TILES, DEFAULT_DASHBOARD); + // The stat groups of the shown stat tiles, and TREND for the pile card. Waits for the saved layout in /me. + const statGroups = [...new Set([ + ...layout.map((tile) => statGroup(tile.id)).filter(Boolean), + ...(layout.some((tile) => tile.id === 'PILE_OF_SHAME') ? ['TREND'] : []), + ])].sort(); + // No groups would mean "all groups" to the stats endpoint. + const hasStatGroups = statGroups.length > 0; + const stats = useUserStats(statGroups, expireSession, hasSession() && userData != null && hasStatGroups); + const reloadStats = stats.reload; const fetchSteamCount = useCallback(async () => { try { @@ -80,7 +94,7 @@ const Dashboard = () => { } if (r.ok) { const page = await r.json(); - setTopGames((page.content ?? []).slice(0, TOP_GAMES)); + setTopGames((page.content ?? []).slice(0, TOP_GAMES.L)); } } catch (err) { console.error('Error fetching top games:', err); @@ -96,7 +110,7 @@ const Dashboard = () => { } if (r.ok) { const page = await r.json(); - setPlaying((page.content ?? []).slice(0, PLAYING_GAMES)); + setPlaying((page.content ?? []).slice(0, PLAYING_GAMES.L)); } } catch (err) { console.error('Error fetching playing games:', err); @@ -172,8 +186,8 @@ const Dashboard = () => { fetchSummary(); fetchActivity(); fetchAchievements(); - reloadTrend(); - }, [fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements, reloadTrend]); + if (hasStatGroups) reloadStats(); + }, [fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements, hasStatGroups, reloadStats]); useEffect(() => { const fetchUserData = async () => { @@ -240,7 +254,43 @@ const Dashboard = () => { } const syncBusy = forceSync.busy || sync.isRunning; - const maxMinutes = topGames[0]?.playtimeMinutes || 1; + + const saveLayout = async (next) => { + // The default is saved as "never changed", so a later change of the default reaches this user too. + const tiles = JSON.stringify(next) === JSON.stringify(DEFAULT_DASHBOARD) ? null : next; + if (!await runSave(() => saveTiles('dashboard-tiles', tiles))) return; + setUserData((d) => ({...d, settings: {...d.settings, dashboardTiles: tiles}})); + setEditing(false); + }; + + const renderTile = (id, size) => { + switch (id) { + case 'GAME_COUNT': return showLibrary && ; + case 'MOST_PLAYED': + return showLibrary && ; + case 'PLAYING': + return showLibrary && ( + + ); + case 'PILE_OF_SHAME': + if (!showLibrary) return null; + if (summary) { + return ; + } + return summaryFailed && ( + + {t('DASHBOARD.SUMMARY_FAILED')} + + + ); + case 'ACTIVITY': return hasSteamId && activity && ; + case 'ACHIEVEMENTS': return hasSteamId && achievements && ; + default: return statGroupTile(id, stats.stats); + } + }; return ( @@ -272,101 +322,88 @@ const Dashboard = () => {
      - {hasSteamId && ( -
      - - -
      - )} +
      + {hasSteamId && ( + <> + + + + )} + +
      - {showLibrary && ( -
      - - - -
      -

      - - {t('DASHBOARD.MOST_PLAYED')} -

      - -
      - - {topGames.length === 0 ? ( -

      - {sync.isRunning ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')} -

      - ) : ( -
        - {topGames.map((g, index) => ( -
      1. - - - {index + 1} - - -
        -
        - {g.name ?? `App ${g.appid}`} -
        -
        -
        -
        -
        - - {formatPlaytime(g.playtimeMinutes)} - - -
      2. - ))} -
      - )} -
      -
      - )} - - {showLibrary && ( -
      - - {summary && ( - - )} - {!summary && summaryFailed && ( - - {t('DASHBOARD.SUMMARY_FAILED')} - - - )} -
      - )} - - {hasSteamId && activity && } - {hasSteamId && achievements && } + setEditing(false)}/> {/* Last child: space-y gives every other child a bottom margin, which would move the fixed card. */} ); }; +function MostPlayedCard({games, syncing}) { + const {t} = useTranslation(); + const maxMinutes = games[0]?.playtimeMinutes || 1; + return ( + +
      +

      + + {t('DASHBOARD.MOST_PLAYED')} +

      + +
      + + {games.length === 0 ? ( +

      + {syncing ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')} +

      + ) : ( +
        + {games.map((g, index) => ( +
      1. + + + {index + 1} + + +
        +
        + {g.name ?? `App ${g.appid}`} +
        +
        +
        +
        +
        + + {formatPlaytime(g.playtimeMinutes)} + + +
      2. + ))} +
      + )} +
      + ); +} + function DashboardSkeleton() { return ( diff --git a/src/pages/Dashboard.test.jsx b/src/pages/Dashboard.test.jsx index af9469d..be3c9f3 100644 --- a/src/pages/Dashboard.test.jsx +++ b/src/pages/Dashboard.test.jsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; -import {cleanup, render, screen, waitFor} from '@testing-library/react'; +import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; import {MemoryRouter, Route, Routes, useLocation} from 'react-router-dom'; import Dashboard from './Dashboard.jsx'; import {PreferencesProvider} from '../context/PreferencesContext.jsx'; @@ -49,9 +49,13 @@ describe('Dashboard', () => { }); const avatar = 'https://avatars.steamstatic.com/abc_full.jpg'; - const mockDashboard = () => { - globalThis.fetch = vi.fn(async (url) => { - if (url.includes('/api/users/me')) return ok({email: 'a@b.de', displayName: 'Lobo', steamId: '76561198000000000'}); + const games = Array.from({length: 12}, (_, i) => ({appid: 100 + i, name: `Game ${i}`, playtimeMinutes: 1000 - i})); + const mockDashboard = (settings) => { + globalThis.fetch = vi.fn(async (url, init) => { + if (init?.method === 'PUT') return {ok: true, status: 204, json: async () => ({})}; + if (url.includes('/api/users/me')) return ok({email: 'a@b.de', displayName: 'Lobo', steamId: '76561198000000000', settings}); + if (url.includes('sort=playtimeMinutes') && !url.includes('status=')) return ok({content: games, total: 12}); + if (url.includes('/api/library/stats')) return ok({trend: null, habits: {moonTrips: 1, longestDay: null, underOneHourGames: 0, underOneHourShare: null}}); if (url.includes('/api/library/steam-profile')) return ok({personaName: 'Lobo', avatarUrl: avatar}); if (url.includes('/api/library/activity')) return ok({totalMinutes: 90, days: [{day: '2026-09-23', minutes: 0}, {day: '2026-09-24', minutes: 90}], recentGames: [ {appid: 1086940, name: "Baldur's Gate 3", playtimeMinutes: 900, playtime2Weeks: 90, status: 'PLAYING'}]}); @@ -74,6 +78,55 @@ describe('Dashboard', () => { expect(container.querySelector('img[src$="/1086940/library_hero.jpg"]')).not.toBeNull(); }); + const order = (container) => [...container.querySelectorAll('[data-tile]')].map((el) => el.dataset.tile); + + it('shows today\'s tiles without a saved layout', async () => { + const container = mockDashboard(); + + await screen.findByText('DASHBOARD.ACHIEVEMENTS.TITLE'); + expect(order(container)).toEqual(['GAME_COUNT', 'MOST_PLAYED', 'PLAYING', 'PILE_OF_SHAME', 'ACTIVITY', 'ACHIEVEMENTS']); + expect(screen.getByText('Game 4')).toBeTruthy(); + expect(screen.queryByText('Game 5')).toBeNull(); + }); + + it('shows a saved layout with its sizes and loads the stats of its stat tiles', async () => { + const container = mockDashboard({dashboardTiles: [{id: 'STATS_HABITS', size: 'L'}, {id: 'MOST_PLAYED', size: 'S'}]}); + + await screen.findByText('STATS.HABITS.TITLE'); + expect(order(container)).toEqual(['STATS_HABITS', 'MOST_PLAYED']); + expect(screen.getByText('Game 2')).toBeTruthy(); + expect(screen.queryByText('Game 3')).toBeNull(); + // No pile tile, so no TREND. + expect(fetch).toHaveBeenCalledWith(expect.stringMatching(/\/api\/library\/stats\?groups=HABITS$/), expect.anything()); + }); + + it('leaves out a stat tile whose data did not come', async () => { + const container = mockDashboard({dashboardTiles: [{id: 'STATS_PILE', size: 'M'}, {id: 'MOST_PLAYED', size: 'M'}]}); + + await screen.findByText('Game 0'); + expect(order(container)).toEqual(['MOST_PLAYED']); + }); + + it('saves the default layout as "never changed"', async () => { + mockDashboard({dashboardTiles: [{id: 'MOST_PLAYED', size: 'S'}]}); + + fireEvent.click(await screen.findByRole('button', {name: 'TILES.CUSTOMIZE'})); + fireEvent.click(screen.getByRole('button', {name: 'TILES.RESET'})); + fireEvent.click(screen.getByRole('button', {name: 'TILES.DONE'})); + + await waitFor(() => expect(fetch.mock.calls.some(([, init]) => init?.method === 'PUT')).toBe(true)); + const [url, init] = fetch.mock.calls.find(([, i]) => i?.method === 'PUT'); + expect(url).toMatch(/\/api\/users\/settings\/dashboard-tiles$/); + expect(JSON.parse(init.body)).toEqual({tiles: null}); + expect(await screen.findByRole('button', {name: 'TILES.CUSTOMIZE'})).toBeTruthy(); + }); + it('puts "Customize" into the welcome card', async () => { + mockDashboard(); + + const button = await screen.findByRole('button', {name: 'TILES.CUSTOMIZE'}); + expect(button.closest('[data-slot="card"]')?.textContent ?? 'not in a card').toContain('DASHBOARD.WELCOME_BACK'); + }); + it('shows the achievement summary', async () => { mockDashboard(); diff --git a/src/pages/GameDetail.test.jsx b/src/pages/GameDetail.test.jsx index 14826d0..c84b76b 100644 --- a/src/pages/GameDetail.test.jsx +++ b/src/pages/GameDetail.test.jsx @@ -242,30 +242,30 @@ describe('GameDetail of a game added by hand', () => { expect(screen.queryByText('MANUAL_GAME.TITLE')).toBeNull(); }); - it('keeps a section hidden after the page loaded when it sets the profile art', async () => { - let sections = ['PLAYING']; + it('keeps a background set in another tab when it sets the profile banner', async () => { + let backgroundAppid = null; globalThis.fetch = vi.fn(async (url, init) => ({ok: true, status: init?.method === 'PUT' ? 204 : 200, json: async () => (url.includes('/api/users/me') - ? {role: 'USER', settings: {profileStyle: {accent: 'red', corners: 'round', sections}}} + ? {role: 'USER', settings: {profileStyle: {accent: 'red', corners: 'round', backgroundAppid}}} : {...game, source: 'STEAM'})})); renderDetail(); const menu = await screen.findByRole('button', {name: 'GAME_DETAIL.PROFILE_ART'}); - sections = []; // the owner hides PLAYING in Settings, e.g. in a second tab + backgroundAppid = 620; // set in a second tab fireEvent.keyDown(menu, {key: 'Enter'}); fireEvent.click(await screen.findByRole('menuitemcheckbox', {name: 'GAME_DETAIL.PROFILE_BANNER'})); await waitFor(() => expect(fetch.mock.calls.some(([u, init]) => init?.method === 'PUT' && u.endsWith('/profile-style'))).toBe(true)); const sent = fetch.mock.calls.filter(([u, init]) => init?.method === 'PUT' && u.endsWith('/profile-style')) .map(([, init]) => JSON.parse(init.body)); - expect(sent.filter((body) => body.sections?.includes('PLAYING'))).toEqual([]); + expect(sent).toEqual([{accent: 'red', corners: 'round', bannerAppid: 220, backgroundAppid: 620}]); }); it('keeps a banner picked in another tab when it sets this game as banner', async () => { let bannerAppid = 220; globalThis.fetch = vi.fn(async (url, init) => ({ok: true, status: init?.method === 'PUT' ? 204 : 200, json: async () => (url.includes('/api/users/me') - ? {role: 'USER', settings: {profileStyle: {bannerAppid, sections: ['PLAYING']}}} + ? {role: 'USER', settings: {profileStyle: {bannerAppid}}} : {...game, source: 'STEAM'})})); renderDetail(); const menu = await screen.findByRole('button', {name: 'GAME_DETAIL.PROFILE_ART'}); @@ -280,7 +280,7 @@ describe('GameDetail of a game added by hand', () => { }); it('uses the art as profile banner and removes it on the second click', async () => { - let stored = {accent: 'red', corners: 'round', sections: ['PLAYING']}; + let stored = {accent: 'red', corners: 'round'}; globalThis.fetch = vi.fn(async (url, init) => { if (init?.method === 'PUT') stored = JSON.parse(init.body); return {ok: true, status: init?.method === 'PUT' ? 204 : 200, @@ -296,7 +296,7 @@ describe('GameDetail of a game added by hand', () => { await openMenu(); fireEvent.click(await screen.findByRole('menuitemcheckbox', {name: 'GAME_DETAIL.PROFILE_BANNER'})); await waitFor(() => expect(styleBodies()).toEqual([{accent: 'red', corners: 'round', bannerAppid: 220, - backgroundAppid: null, sections: ['PLAYING']}])); + backgroundAppid: null}])); await openMenu(); const banner = await screen.findByRole('menuitemcheckbox', {name: 'GAME_DETAIL.PROFILE_BANNER'}); diff --git a/src/pages/PublicProfilePage.jsx b/src/pages/PublicProfilePage.jsx index 003b15b..25b4686 100644 --- a/src/pages/PublicProfilePage.jsx +++ b/src/pages/PublicProfilePage.jsx @@ -1,24 +1,30 @@ import {useEffect, useState} from 'react'; import {Link, useParams} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; -import {CircleCheck, Clock, Gamepad2, Library, Percent, Star, Trophy, UserRoundX} from 'lucide-react'; +import {CircleCheck, Clock, Gamepad2, LayoutGrid, Library, Percent, Star, Trophy, UserRoundX} from 'lucide-react'; +import {Button} from '@/components/ui/button'; import {Card} from '@/components/ui/card'; import {Skeleton} from '@/components/ui/skeleton'; +import {cn} from '@/lib/utils'; import GameCover from '../components/GameCover.jsx'; import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx'; import {GAME_STATUSES} from '../utils/backlog.js'; import {formatPlaytime} from '../utils/games.js'; +import {hasSession} from '../utils/auth.js'; import {normalizeProfileStyle} from '../utils/profileStyle.js'; +import {DEFAULT_PROFILE, PROFILE_TILES, PROFILE_TITLES, normalizeLayout, saveTiles} from '../utils/tiles.js'; +import useCurrentUser from '../hooks/useCurrentUser.js'; +import useSaveResponse from '../hooks/useSaveResponse.js'; import HeroBanner from '../components/HeroBanner.jsx'; import PageBackground from '../components/PageBackground.jsx'; import PageContainer from '../components/PageContainer.jsx'; -import PileStats from '../components/stats/PileStats.jsx'; -import TrendStats from '../components/stats/TrendStats.jsx'; -import TopGamesStats from '../components/stats/TopGamesStats.jsx'; -import TasteStats from '../components/stats/TasteStats.jsx'; -import HabitsStats from '../components/stats/HabitsStats.jsx'; -import FinishedStats from '../components/stats/FinishedStats.jsx'; +import TileGrid from '../components/TileGrid.jsx'; +import statGroupTile from '../components/stats/statGroupTile.jsx'; + +// Games per tile size; Core sends at most 8 playing and 12 finished games. +const PLAYING_GAMES = {S: 4, M: 8}; +const FINISHED_GAMES = {S: 6, M: 12}; /** * Public, no login. Auth returns a short-lived ticket for a public profile; Core returns the data for it. @@ -29,6 +35,10 @@ export default function PublicProfilePage() { const [profile, setProfile] = useState(null); const [library, setLibrary] = useState(null); const [error, setError] = useState(null); + const [editing, setEditing] = useState(false); + const [reloads, setReloads] = useState(0); + const viewer = useCurrentUser(hasSession()); + const runSave = useSaveResponse(); useEffect(() => { let cancelled = false; @@ -70,7 +80,7 @@ export default function PublicProfilePage() { return () => { cancelled = true; }; - }, [name, t]); + }, [name, t, reloads]); if (error) { return ( @@ -97,13 +107,31 @@ export default function PublicProfilePage() { const memberSince = profile.memberSince ? new Date(`${profile.memberSince}T12:00:00`).toLocaleDateString(i18n.language, {year: 'numeric', month: 'long'}) : null; - const sections = { - SUMMARY: library.totalGames != null && , - STATUS: library.byStatus && Object.values(library.byStatus).some((count) => count > 0) - && , - PLAYING: library.playing?.length > 0 && , - FINISHED: library.finished?.length > 0 && , - STATS: library.stats && , + const layout = normalizeLayout(profile.style?.tiles, PROFILE_TILES, DEFAULT_PROFILE); + const owner = viewer?.displayName?.toLowerCase() === profile.displayName.toLowerCase(); + const renderTile = (id, size) => { + switch (id) { + case 'SUMMARY': return library.totalGames != null && ; + case 'STATUS': + return library.byStatus && Object.values(library.byStatus).some((count) => count > 0) + && ; + case 'PLAYING': + return library.playing?.length > 0 + && ; + case 'FINISHED': + return library.finished?.length > 0 && ; + default: return statGroupTile(id, library.stats, false); + } + }; + // Core sends only the data of the shown tiles (the ticket says which), so a new tile needs a new load. + const saveLayout = async (next) => { + if (!await runSave(() => saveTiles('profile-tiles', next))) return; + setEditing(false); + if (next.some((tile) => !layout.some((shown) => shown.id === tile.id))) { + setReloads((count) => count + 1); + } else { + setProfile((p) => ({...p, style: {...p.style, tiles: next}})); + } }; // The owner's accent and corners apply to this page only; light or dark mode stays the viewer's. @@ -120,16 +148,20 @@ export default function PublicProfilePage() {

      {t('PROFILE.MEMBER_SINCE', {date: memberSince})}

      )} + {owner && !editing && ( + + )} - {style.sections.map((section) => sections[section] && ( -
      {sections[section]}
      - ))} + setEditing(false)}/> ); } -function Summary({library}) { +function Summary({library, compact}) { const {t, i18n} = useTranslation(); const stats = [ [Library, library.totalGames.toLocaleString(i18n.language), t('PROFILE.GAMES')], @@ -143,7 +175,7 @@ function Summary({library}) { ); } return ( - 3 ? 'grid-cols-2 sm:grid-cols-5' : 'grid-cols-3'}`}> + 3 && !compact ? 'grid-cols-2 sm:grid-cols-5' : 'grid-cols-3')}> {stats.map(([Icon, value, label]) => (
      @@ -178,14 +210,14 @@ function StatusBar({byStatus}) { ); } -function Playing({games}) { +function Playing({games, compact}) { const {t} = useTranslation(); return (

      {t('PROFILE.PLAYING')}

      -
        +
          {games.map((g) => (
        • @@ -224,17 +256,3 @@ function Finished({games}) { ); } - -// Only the groups the user shares come back; the game rows do not link, the library pages are private. -function SharedStats({stats}) { - return ( -
          - {stats.pile && } - {stats.trend && } - {stats.topGames && } - {stats.taste && } - {stats.habits && } - {stats.finished && } -
          - ); -} diff --git a/src/pages/PublicProfilePage.test.jsx b/src/pages/PublicProfilePage.test.jsx index 8373f2f..03bb883 100644 --- a/src/pages/PublicProfilePage.test.jsx +++ b/src/pages/PublicProfilePage.test.jsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import {afterEach, describe, expect, it, vi} from 'vitest'; -import {cleanup, render, screen, waitFor} from '@testing-library/react'; +import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; import {MemoryRouter, Route, Routes} from 'react-router-dom'; import PublicProfilePage from './PublicProfilePage.jsx'; import {PreferencesProvider} from '../context/PreferencesContext.jsx'; @@ -14,8 +14,10 @@ const library = { finished: [{appid: 620, name: 'Portal 2', playtimeMinutes: 900, status: 'COMPLETED', rating: 5}], }; -function mockBackends({profileStatus = 200, libraryStatus = 200, style, answer = library} = {}) { - globalThis.fetch = vi.fn(async (url) => { +function mockBackends({profileStatus = 200, libraryStatus = 200, style, answer = library, me} = {}) { + globalThis.fetch = vi.fn(async (url, init) => { + if (init?.method === 'PUT') return {ok: true, status: 204, json: async () => ({})}; + if (url.includes('/api/users/me')) return {ok: !!me, status: me ? 200 : 401, json: async () => me}; if (url.includes('/api/public/profiles/library')) { return {ok: libraryStatus === 200, status: libraryStatus, json: async () => answer}; } @@ -65,7 +67,7 @@ describe('PublicProfilePage', () => { it('shows only the stat groups the user shares, without links', async () => { library.stats = {pile: null, trend: null, topGames: null, taste: null, finished: null, habits: {moonTrips: 2.5, longestDay: null, underOneHourGames: 0, underOneHourShare: null}}; - mockBackends(); + mockBackends({style: {tiles: [{id: 'STATS_HABITS', size: 'M'}, {id: 'STATS_PILE', size: 'M'}]}}); renderPage(); expect(await screen.findByText('STATS.HABITS.TITLE')).toBeTruthy(); @@ -75,7 +77,7 @@ describe('PublicProfilePage', () => { }); it('applies the owner\'s accent and corners to the page only, with banner and background', async () => { - mockBackends({style: {accent: 'red', corners: 'round', bannerAppid: 620, backgroundAppid: 220, sections: ['PLAYING']}}); + mockBackends({style: {accent: 'red', corners: 'round', bannerAppid: 620, backgroundAppid: 220, tiles: [{id: 'PLAYING', size: 'M'}]}}); const {container} = renderPage(); await screen.findByText('Half-Life 2'); @@ -88,13 +90,13 @@ describe('PublicProfilePage', () => { it('shows the sections in the owner\'s order and leaves out hidden ones', async () => { mockBackends({ - style: {sections: ['FINISHED', 'PLAYING', 'SUMMARY']}, + style: {tiles: [{id: 'FINISHED', size: 'M'}, {id: 'PLAYING', size: 'S'}, {id: 'SUMMARY', size: 'M'}]}, answer: {...library, byStatus: null, finishedGames: 60}, }); const {container} = renderPage(); await screen.findByText('Portal 2'); - expect([...container.querySelectorAll('[data-section]')].map((el) => el.dataset.section)) + expect([...container.querySelectorAll('[data-tile]')].map((el) => el.dataset.tile)) .toEqual(['FINISHED', 'PLAYING', 'SUMMARY']); expect(screen.getByText('60')).toBeTruthy(); expect(screen.queryByText('BACKLOG.STATUS.BACKLOG')).toBeNull(); @@ -105,7 +107,7 @@ describe('PublicProfilePage', () => { const {container} = renderPage(); await screen.findByText('PROFILE.GAMES'); - expect([...container.querySelectorAll('[data-section]')].map((el) => el.dataset.section)).toEqual(['SUMMARY']); + expect([...container.querySelectorAll('[data-tile]')].map((el) => el.dataset.tile)).toEqual(['SUMMARY']); }); it('shows every section for an Auth without a style', async () => { @@ -113,11 +115,84 @@ describe('PublicProfilePage', () => { const {container} = renderPage(); await screen.findByText('Portal 2'); - expect([...container.querySelectorAll('[data-section]')].map((el) => el.dataset.section)) + expect([...container.querySelectorAll('[data-tile]')].map((el) => el.dataset.tile)) .toEqual(['SUMMARY', 'STATUS', 'PLAYING', 'FINISHED']); expect(container.querySelector('[data-accent="violet"]')).toBeTruthy(); }); + it('shows the small playing tile with half the games and at half width', async () => { + const games = Array.from({length: 8}, (_, i) => ({appid: 100 + i, name: `Game ${i}`, playtimeMinutes: 60, status: 'PLAYING'})); + mockBackends({style: {tiles: [{id: 'PLAYING', size: 'S'}]}, answer: {...library, playing: games}}); + const {container} = renderPage(); + + await screen.findByText('Game 0'); + expect(screen.queryByText('Game 4')).toBeNull(); + expect(container.querySelector('[data-tile="PLAYING"]').className).toContain('md:col-span-6'); + }); + + const libraryLoads = () => fetch.mock.calls.filter(([url]) => url.includes('/api/public/profiles/library')).length; + const putBody = () => JSON.parse(fetch.mock.calls.find(([, init]) => init?.method === 'PUT')[1].body); + + it('offers "Customize" to the owner and hides a tile without a new load', async () => { + localStorage.setItem('tbp_session', '1'); + try { + mockBackends({me: {displayName: 'lobo', role: 'USER'}}); + const {container} = renderPage(); + + fireEvent.click(await screen.findByRole('button', {name: 'TILES.CUSTOMIZE'})); + // The test texts carry no tile name; the first tile is SUMMARY. + fireEvent.click(screen.getAllByRole('button', {name: 'TILES.HIDE'})[0]); + fireEvent.click(screen.getByRole('button', {name: 'TILES.DONE'})); + + expect(await screen.findByRole('button', {name: 'TILES.CUSTOMIZE'})).toBeTruthy(); + expect(fetch.mock.calls.find(([, init]) => init?.method === 'PUT')[0]).toMatch(/\/api\/users\/settings\/profile-tiles$/); + expect(putBody().tiles.map((tile) => tile.id)).toEqual(['STATUS', 'PLAYING', 'FINISHED']); + expect([...container.querySelectorAll('[data-tile]')].map((el) => el.dataset.tile)).not.toContain('SUMMARY'); + expect(libraryLoads()).toBe(1); + } finally { + localStorage.clear(); + } + }); + + it('loads again after adding a tile, because Core sent no data for it', async () => { + localStorage.setItem('tbp_session', '1'); + try { + mockBackends({me: {displayName: 'Lobo', role: 'USER'}}); + renderPage(); + + fireEvent.click(await screen.findByRole('button', {name: 'TILES.CUSTOMIZE'})); + fireEvent.click(screen.getByRole('button', {name: 'STATS.PILE.TITLE'})); + fireEvent.click(screen.getByRole('button', {name: 'TILES.DONE'})); + + await waitFor(() => expect(libraryLoads()).toBe(2)); + expect(putBody().tiles.at(-1)).toEqual({id: 'STATS_PILE', size: 'M'}); + } finally { + localStorage.clear(); + } + }); + + it('shows no "Customize" to a visitor', async () => { + localStorage.setItem('tbp_session', '1'); + try { + mockBackends({me: {displayName: 'Someone', role: 'USER'}}); + renderPage(); + + await screen.findByText('Portal 2'); + await waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/users/me'), expect.anything())); + expect(screen.queryByRole('button', {name: 'TILES.CUSTOMIZE'})).toBeNull(); + } finally { + localStorage.clear(); + } + }); + it('keeps three numbers in a row on a phone', async () => { + mockBackends(); + const {container} = renderPage(); + + await screen.findByText('PROFILE.GAMES'); + const summary = container.querySelector('[data-tile="SUMMARY"] [data-slot="card"]'); + expect(summary.className.split(' ')).toContain('grid-cols-3'); + }); + it('says the profile is unknown or private on a 404', async () => { mockBackends({profileStatus: 404}); renderPage(); diff --git a/src/pages/SettingsPage.jsx b/src/pages/SettingsPage.jsx index 104c3e1..36cf47f 100644 --- a/src/pages/SettingsPage.jsx +++ b/src/pages/SettingsPage.jsx @@ -18,7 +18,6 @@ import BuildVersionCard from '../components/BuildVersionCard.jsx'; import DeleteAccountCard from '../components/DeleteAccountCard.jsx'; import FormField from '../components/FormField.jsx'; import SteamAccountCard from '../components/SteamAccountCard.jsx'; -import ProfileStatsSettings from '../components/stats/ProfileStatsSettings.jsx'; import {settingsSchema} from '../utils/validation.js'; import {usePreferences} from '../context/PreferencesContext.jsx'; import useSessionExpired from '../hooks/useSessionExpired.js'; @@ -28,7 +27,6 @@ import ProfileStyleSettings from '../components/ProfileStyleSettings.jsx'; import {normalizeProfileStyle} from '../utils/profileStyle.js'; import useSaveProfileStyle from '../hooks/useSaveProfileStyle.js'; import {coverUrl} from '../utils/games.js'; -import {saveProfileStats} from '../utils/stats.js'; import {apiFetch, refreshSession} from '../utils/api.js'; import {hasSession, markSession} from '../utils/auth.js'; @@ -66,7 +64,6 @@ export default function SettingsPage() { const [profile, setProfile] = useState(null); const [isLoading, setIsLoading] = useState(true); const [loadError, setLoadError] = useState(null); - const [savingProfileStats, setSavingProfileStats] = useState(false); const [savingProfileStyle, setSavingProfileStyle] = useState(false); const [searchParams, setSearchParams] = useSearchParams(); // Read once: removing the parameter below must not load the profile again. @@ -206,31 +203,7 @@ export default function SettingsPage() { } }; - // Shown at once; the switches wait for the answer, so the saves reach Auth one after the other. - const handleProfileStatsChange = async (groups) => { - const previous = profile?.settings?.profileStats ?? []; - const showProfileStats = (next) => setProfile((p) => ({...p, settings: {...p.settings, profileStats: next}})); - showProfileStats(groups); - setSavingProfileStats(true); - try { - const response = await saveProfileStats(groups); - if (response.status === 401) { - expireSession(); - return; - } - if (!response.ok) { - showProfileStats(previous); - toast.error(t('SETTINGS.ERROR_SAVE')); - } - } catch (err) { - console.error('Profile stats save error:', err); - showProfileStats(previous); - toast.error(t('INFO.ERROR.NETWORK_FAILURE')); - } finally { - setSavingProfileStats(false); - } - }; - + // Shown at once; the pickers wait for the answer, so the saves reach Auth one after the other. const handleProfileStyleChange = async (changes) => { const previous = normalizeProfileStyle(profile?.settings?.profileStyle); const showProfileStyle = (next) => setProfile((p) => ({...p, settings: {...p.settings, profileStyle: next}})); @@ -447,9 +420,9 @@ export default function SettingsPage() {
      )} - + {publicProfile && profileLink && ( +

      {t('SETTINGS.PROFILE_TILES_HINT')}

      + )}
      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']); + }); +});