diff --git a/README.md b/README.md index bf3b3dd..4c18376 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,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). + 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. - **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 626639f..2e26457 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -36,8 +36,24 @@ }, "SETTINGS": { "PROFILE_STATS": "Statistik im öffentlichen Profil", - "PROFILE_STATS_HINT": "Wähle, was Besucher sehen. Deine Summen und Status sind immer da.", + "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", @@ -238,6 +254,9 @@ "TTB_COMPLETELY": "100 %", "SET_BACKGROUND": "Als Hintergrund", "REMOVE_BACKGROUND": "Hintergrund entfernen", + "PROFILE_ART": "Profilbild", + "PROFILE_BANNER": "Banner im Profil", + "PROFILE_BACKGROUND": "Hintergrund im Profil", "SET_BANNER": "Als Banner", "ALL_UNLOCKED": "Du hast alle Erfolge dieses Spiels freigeschaltet.", "SET_COMPLETED": "Auf 100 % setzen", diff --git a/public/i18n/en.json b/public/i18n/en.json index de8d3a6..c2a32d4 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -36,8 +36,24 @@ }, "SETTINGS": { "PROFILE_STATS": "Stats on the public profile", - "PROFILE_STATS_HINT": "Pick what visitors see. Your totals and statuses are always there.", + "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", @@ -238,6 +254,9 @@ "TTB_COMPLETELY": "100%", "SET_BACKGROUND": "Use as background", "REMOVE_BACKGROUND": "Remove background", + "PROFILE_ART": "Profile art", + "PROFILE_BANNER": "Profile banner", + "PROFILE_BACKGROUND": "Profile background", "SET_BANNER": "Use as banner", "ALL_UNLOCKED": "You unlocked every achievement of this game.", "SET_COMPLETED": "Set to 100 %", diff --git a/src/App.jsx b/src/App.jsx index 9388997..088f810 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -57,7 +57,8 @@ function App() { {/* isolate: the background layer sits behind the content but in front of this div's own background. */}
- {isAuthenticated && } + {/* A public profile shows the owner's background instead. */} + {isAuthenticated && !location.pathname.startsWith('/u/') && }
diff --git a/src/components/HeroBanner.jsx b/src/components/HeroBanner.jsx new file mode 100644 index 0000000..989ecbe --- /dev/null +++ b/src/components/HeroBanner.jsx @@ -0,0 +1,17 @@ +import {cn} from '@/lib/utils'; +import HeroArt from './HeroArt.jsx'; + +/** + * The art of a game behind a card's content, or the accent gradient without one. Put it first in a card with + * `relative overflow-hidden`; the gradient keeps the text readable. + */ +export default function HeroBanner({appid}) { + return ( + <> + +
+ + ); +} diff --git a/src/components/ProfileArtMenu.jsx b/src/components/ProfileArtMenu.jsx new file mode 100644 index 0000000..16d802a --- /dev/null +++ b/src/components/ProfileArtMenu.jsx @@ -0,0 +1,47 @@ +import {useState} from 'react'; +import {useTranslation} from 'react-i18next'; +import {UserRound} from 'lucide-react'; + +import {Button} from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import useSaveProfileStyle from '../hooks/useSaveProfileStyle.js'; + +/** + * Uses the art of this Steam game as banner or background of the public profile; a checked item removes it again. + * + * @param onSaved gets the whole saved style + */ +export default function ProfileArtMenu({appid, style, onSaved}) { + const {t} = useTranslation(); + const save = useSaveProfileStyle(); + const [saving, setSaving] = useState(false); + + const toggle = async (field) => { + setSaving(true); + const saved = await save((stored) => ({[field]: stored[field] === appid ? null : appid}), style); + if (saved) onSaved(saved); + setSaving(false); + }; + + return ( + + + + + + toggle('bannerAppid')}> + {t('GAME_DETAIL.PROFILE_BANNER')} + + toggle('backgroundAppid')}> + {t('GAME_DETAIL.PROFILE_BACKGROUND')} + + + + ); +} diff --git a/src/components/ProfileStyleSettings.jsx b/src/components/ProfileStyleSettings.jsx new file mode 100644 index 0000000..d904d8f --- /dev/null +++ b/src/components/ProfileStyleSettings.jsx @@ -0,0 +1,97 @@ +import {useTranslation} from 'react-i18next'; +import {ArrowDown, ArrowUp, 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}) { + const {t} = useTranslation(); + return ( +
+
+ + {!appid &&

{t('SETTINGS.PROFILE_ART_HINT')}

} +
+ {appid && ( +
+ { event.currentTarget.hidden = true; }} + className="h-10 w-20 rounded-md object-cover"/> + +
+ )} +
+ ); +} + +/** + * The look of the public profile: accent, corners, banner, background and the sections in their order. + * + * @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 ( +
+
+ {t('SETTINGS.PROFILE_STYLE')} +

{t('SETTINGS.PROFILE_STYLE_HINT')}

+
+ onChange({accent})}/> + onChange({corners})}/> + 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/StylePickers.jsx b/src/components/StylePickers.jsx new file mode 100644 index 0000000..1a78801 --- /dev/null +++ b/src/components/StylePickers.jsx @@ -0,0 +1,53 @@ +import {useTranslation} from 'react-i18next'; + +import {Button} from '@/components/ui/button'; +import {Label} from '@/components/ui/label'; +import {cn} from '@/lib/utils'; +import {ACCENTS, CORNERS} from '../utils/appearance.js'; + +/** + * One round button per accent preset; each button carries its own preset, so it shows its own color. + * + * @param id prefix for the label id, unique on the page + */ +export function AccentPicker({id, value, onChange, disabled}) { + const {t} = useTranslation(); + return ( +
+ +
+ {ACCENTS.map((accent) => ( +
+
+ ); +} + +/** + * @param id prefix for the label id, unique on the page + */ +export function CornersPicker({id, value, onChange, disabled}) { + const {t} = useTranslation(); + return ( +
+ +
+ {CORNERS.map((corners) => ( + + ))} +
+
+ ); +} diff --git a/src/hooks/useCurrentUser.js b/src/hooks/useCurrentUser.js index 992a44d..2fa1d5a 100644 --- a/src/hooks/useCurrentUser.js +++ b/src/hooks/useCurrentUser.js @@ -1,9 +1,11 @@ import {useEffect, useState} from 'react'; import {apiFetch} from '../utils/api.js'; import {sessionStamp} from '../utils/auth.js'; +import {normalizeProfileStyle} from '../utils/profileStyle.js'; /** - * The stored role and the active role ("view as") of the logged-in user, or null while loading and on any error. + * 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. * Loads again after a new login or a role switch (see sessionStamp) on the next render. */ export default function useCurrentUser(enabled) { @@ -20,7 +22,8 @@ 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, + profileStyle: normalizeProfileStyle(data.settings?.profileStyle)}); } catch { if (!cancelled) setUser(null); } diff --git a/src/hooks/useSaveProfileStyle.js b/src/hooks/useSaveProfileStyle.js new file mode 100644 index 0000000..ae96fe3 --- /dev/null +++ b/src/hooks/useSaveProfileStyle.js @@ -0,0 +1,36 @@ +import {useCallback} from 'react'; +import {useTranslation} from 'react-i18next'; +import {toast} from 'sonner'; +import useSessionExpired from './useSessionExpired.js'; +import {loadProfileStyle, saveProfileStyle} from '../utils/profileStyle.js'; + +/** + * Returns `save(changes, shown)`. It merges the changes into the stored style, loaded just before, so a change from + * another tab or page stays; `shown` is the fallback when the load fails. `changes` can be a function of that + * 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(); + + return useCallback(async (changes, shown) => { + try { + 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]); +} diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index 811d03c..18c485f 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -21,11 +21,10 @@ import useForceSync from '../hooks/useForceSync.js'; import {formatPlaytime} from '../utils/games.js'; import useSessionExpired from '../hooks/useSessionExpired.js'; import PageContainer from '../components/PageContainer.jsx'; -import HeroArt from '../components/HeroArt.jsx'; +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 {cn} from '@/lib/utils'; import {usePreferences} from '../context/PreferencesContext.jsx'; import {apiFetch} from '../utils/api.js'; import {hasSession} from '../utils/auth.js'; @@ -246,12 +245,8 @@ const Dashboard = () => { return ( - {/* A picked game, else the game played most in the last two weeks; the gradient keeps the text readable. */} - -
+ {/* A picked game, else the game played most in the last two weeks. */} +
{/* The slot is there before the avatar loads, so the title does not jump. */} {hasSteamId && ( diff --git a/src/pages/GameDetail.jsx b/src/pages/GameDetail.jsx index d4d44f5..0bea6c4 100644 --- a/src/pages/GameDetail.jsx +++ b/src/pages/GameDetail.jsx @@ -26,6 +26,7 @@ import useSessionExpired from '../hooks/useSessionExpired.js'; import useSaveAppearance from '../hooks/useSaveAppearance.js'; import {usePreferences} from '../context/PreferencesContext.jsx'; import PageContainer from '../components/PageContainer.jsx'; +import ProfileArtMenu from '../components/ProfileArtMenu.jsx'; import {apiFetch} from '../utils/api.js'; import {ACHIEVEMENT_ICONS, reportBrokenImage} from '../utils/brokenImages.js'; import {canCorrect} from '../utils/corrections.js'; @@ -72,7 +73,10 @@ export default function GameDetail() { const [correcting, setCorrecting] = useState(false); const [merging, setMerging] = useState(false); const [reloads, setReloads] = useState(0); - const auditor = canCorrect(useCurrentUser(true)); + const currentUser = useCurrentUser(true); + const auditor = canCorrect(currentUser); + const [savedProfileStyle, setSavedProfileStyle] = useState(null); + const profileStyle = savedProfileStyle ?? currentUser?.profileStyle; const [celebration, setCelebration] = useState(null); const shownLevel = useRef(null); @@ -269,6 +273,9 @@ export default function GameDetail() { {t('GAME_DETAIL.SET_BANNER')} )} + {profileStyle && ( + + )} {!manual && (
-
-
- -
- {CORNERS.map((corners) => ( - - ))} -
-
+ saveAppearance({accent})}/> + saveAppearance({corners})}/>
@@ -463,6 +451,12 @@ export default function SettingsPage() { disabled={!publicProfile} saving={savingProfileStats} onChange={handleProfileStatsChange}/>
+ +
+ +
diff --git a/src/pages/SettingsPage.test.jsx b/src/pages/SettingsPage.test.jsx index 52e8fee..7482e49 100644 --- a/src/pages/SettingsPage.test.jsx +++ b/src/pages/SettingsPage.test.jsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; -import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; +import {cleanup, fireEvent, render, screen, waitFor, within} from '@testing-library/react'; import {MemoryRouter} from 'react-router-dom'; import SettingsPage from './SettingsPage.jsx'; import {PreferencesProvider} from '../context/PreferencesContext.jsx'; @@ -17,6 +17,8 @@ const profile = (overrides = {}) => ({ settings: {useDarkTheme: true, language: 'de', publicProfile: true}, ...overrides, }); +const appearanceCard = () => within(screen.getByText('SETTINGS.APPEARANCE').closest('[data-slot="card"]')); + const puts = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([, init]) => JSON.parse(init.body)); function renderPage(me) { @@ -99,6 +101,54 @@ describe('SettingsPage public profile', () => { .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']})); + + 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']}])); + 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']})); + + 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']); + }); + + it('removes the profile banner', async () => { + renderPage(styled({bannerAppid: 620})); + + fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.PROFILE_ART_REMOVE'})); + + await waitFor(() => expect(stylePuts()).toHaveLength(1)); + expect(stylePuts()[0].bannerAppid).toBeNull(); + }); + + 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); + 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}})); @@ -142,13 +192,14 @@ describe('SettingsPage appearance', () => { it('saves a picked accent and corner style and marks them as picked', async () => { renderPage(profile()); - fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.GREEN'})); - fireEvent.click(screen.getByRole('button', {name: 'SETTINGS.CORNER_OPTIONS.ROUND'})); + await screen.findByText('SETTINGS.APPEARANCE'); + fireEvent.click(appearanceCard().getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.GREEN'})); + fireEvent.click(appearanceCard().getByRole('button', {name: 'SETTINGS.CORNER_OPTIONS.ROUND'})); await waitFor(() => expect(appearancePuts()).toHaveLength(2)); expect(appearancePuts()[1]).toEqual({accent: 'green', corners: 'round', wideLayout: false, backgroundAppid: null, bannerAppid: null, showBanner: true}); - expect(screen.getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.GREEN'}).getAttribute('aria-pressed')).toBe('true'); - expect(screen.getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.VIOLET'}).getAttribute('aria-pressed')).toBe('false'); + expect(appearanceCard().getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.GREEN'}).getAttribute('aria-pressed')).toBe('true'); + expect(appearanceCard().getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.VIOLET'}).getAttribute('aria-pressed')).toBe('false'); }); it('removes the background', async () => { diff --git a/src/utils/appearance.js b/src/utils/appearance.js index dfae219..f64ff7b 100644 --- a/src/utils/appearance.js +++ b/src/utils/appearance.js @@ -9,7 +9,7 @@ export const DEFAULT_APPEARANCE = { accent: 'violet', corners: 'default', wideLayout: false, backgroundAppid: null, bannerAppid: null, showBanner: true, }; -const appid = (value) => (Number.isInteger(value) && value > 0 ? value : null); +export const appid = (value) => (Number.isInteger(value) && value > 0 ? value : null); const STORAGE_KEY = 'tbp_appearance'; const REQUEST_TIMEOUT_MS = 10000; diff --git a/src/utils/profileStyle.js b/src/utils/profileStyle.js new file mode 100644 index 0000000..e4c3517 --- /dev/null +++ b/src/utils/profileStyle.js @@ -0,0 +1,37 @@ +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) => ({ + accent: ACCENTS.includes(value?.accent) ? value.accent : DEFAULT_PROFILE_STYLE.accent, + 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, +}); + +/** + * The stored style from GET /api/users/me; null when there is no answer to use. + */ +export const loadProfileStyle = async () => { + const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`); + if (!response.ok) return null; + const data = await response.json(); + return normalizeProfileStyle(data.settings?.profileStyle); +}; + +export const saveProfileStyle = (style) => + apiFetch(`${import.meta.env.VITE_API_URL}/api/users/settings/profile-style`, { + method: 'PUT', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(style), + }); diff --git a/src/utils/profileStyle.test.js b/src/utils/profileStyle.test.js new file mode 100644 index 0000000..2c75efb --- /dev/null +++ b/src/utils/profileStyle.test.js @@ -0,0 +1,18 @@ +import {describe, expect, it} from 'vitest'; +import {DEFAULT_PROFILE_STYLE, normalizeProfileStyle} from './profileStyle.js'; + +describe('normalizeProfileStyle', () => { + it('is the default without a style, e.g. from an older Auth', () => { + 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([]); + }); +});