From 2c360c9421d500a38f31eea73dfe5249b81d4a91 Mon Sep 17 00:00:00 2001 From: lobo Date: Thu, 24 Sep 2026 15:06:16 +0200 Subject: [PATCH] feat(appearance): accent color, corners, wide layout and game art background --- public/i18n/de.json | 27 ++- public/i18n/en.json | 27 ++- src/App.jsx | 13 +- src/components/AppHeader.jsx | 5 +- src/components/PageBackground.jsx | 21 +++ src/components/PageBackground.test.jsx | 26 +++ src/components/PageContainer.jsx | 14 ++ src/components/PageContainer.test.jsx | 31 ++++ src/context/PreferencesContext.jsx | 103 ++++++++++- src/hooks/useSaveAppearance.js | 35 ++++ src/hooks/useSaveAppearance.test.jsx | 227 +++++++++++++++++++++++++ src/hooks/useSessionExpired.js | 11 +- src/pages/Dashboard.jsx | 9 +- src/pages/Dashboard.test.jsx | 5 +- src/pages/GameDetail.jsx | 34 +++- src/pages/GameDetail.test.jsx | 19 ++- src/pages/GameLibrary.jsx | 5 +- src/pages/GameLibrary.test.jsx | 5 +- src/pages/PublicProfilePage.jsx | 13 +- src/pages/PublicProfilePage.test.jsx | 5 +- src/pages/SettingsPage.jsx | 71 +++++++- src/pages/SettingsPage.test.jsx | 36 ++++ src/pages/WishlistPage.jsx | 5 +- src/pages/WishlistPage.test.jsx | 5 +- src/style.css | 108 ++++++++++++ src/utils/appearance.js | 65 +++++++ src/utils/appearance.test.js | 52 ++++++ src/utils/games.js | 3 + 28 files changed, 934 insertions(+), 46 deletions(-) create mode 100644 src/components/PageBackground.jsx create mode 100644 src/components/PageBackground.test.jsx create mode 100644 src/components/PageContainer.jsx create mode 100644 src/components/PageContainer.test.jsx create mode 100644 src/hooks/useSaveAppearance.js create mode 100644 src/hooks/useSaveAppearance.test.jsx create mode 100644 src/utils/appearance.js create mode 100644 src/utils/appearance.test.js diff --git a/public/i18n/de.json b/public/i18n/de.json index cd14754..73a9f26 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -64,7 +64,28 @@ "PUBLIC_PROFILE_HINT": "Jeder mit dem Link sieht deine Statistik, was du gerade spielst und deine durchgespielten Spiele mit Bewertung. Notizen, E-Mail und Steam-ID bleiben privat.", "PUBLIC_PROFILE_NEEDS_NAME": "Lege zuerst einen Anzeigenamen fest; er wird Teil des Links.", "PUBLIC_PROFILE_COPY": "Link kopieren", - "PUBLIC_PROFILE_COPIED": "Link kopiert" + "PUBLIC_PROFILE_COPIED": "Link kopiert", + "APPEARANCE": "Aussehen", + "ACCENT": "Akzentfarbe", + "ACCENT_OPTIONS": { + "VIOLET": "Violett", + "BLUE": "Blau", + "GREEN": "Grün", + "ORANGE": "Orange", + "PINK": "Pink", + "RED": "Rot" + }, + "CORNERS": "Ecken", + "CORNER_OPTIONS": { + "SHARP": "Eckig", + "DEFAULT": "Normal", + "ROUND": "Rund" + }, + "WIDE_LAYOUT": "Breites Layout", + "WIDE_LAYOUT_HINT": "Nutzt mehr Platz auf breiten Bildschirmen.", + "BACKGROUND": "Hintergrund", + "BACKGROUND_HINT": "Öffne ein Spiel in deiner Bibliothek und wähle „Als Hintergrund“.", + "BACKGROUND_REMOVE": "Hintergrund entfernen" }, "VALIDATION": { "EMAIL_REQUIRED": "Email ist erforderlich.", @@ -158,7 +179,9 @@ "TTB": "Spieldauer laut IGDB", "TTB_HASTILY": "Story", "TTB_NORMALLY": "+ Extras", - "TTB_COMPLETELY": "100 %" + "TTB_COMPLETELY": "100 %", + "SET_BACKGROUND": "Als Hintergrund", + "REMOVE_BACKGROUND": "Hintergrund entfernen" }, "LANDING": { "STATS_TITLE": "Community-Statistiken", diff --git a/public/i18n/en.json b/public/i18n/en.json index 6b21f8f..0f2b195 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -64,7 +64,28 @@ "PUBLIC_PROFILE_HINT": "Anyone with the link sees your stats, what you play right now and your finished games with ratings. Notes, email and Steam ID stay private.", "PUBLIC_PROFILE_NEEDS_NAME": "Set a display name first; it becomes part of the link.", "PUBLIC_PROFILE_COPY": "Copy link", - "PUBLIC_PROFILE_COPIED": "Link copied" + "PUBLIC_PROFILE_COPIED": "Link copied", + "APPEARANCE": "Appearance", + "ACCENT": "Accent color", + "ACCENT_OPTIONS": { + "VIOLET": "Violet", + "BLUE": "Blue", + "GREEN": "Green", + "ORANGE": "Orange", + "PINK": "Pink", + "RED": "Red" + }, + "CORNERS": "Corners", + "CORNER_OPTIONS": { + "SHARP": "Sharp", + "DEFAULT": "Normal", + "ROUND": "Round" + }, + "WIDE_LAYOUT": "Wide layout", + "WIDE_LAYOUT_HINT": "Uses more of a wide screen.", + "BACKGROUND": "Background", + "BACKGROUND_HINT": "Open a game in your library and choose \"Use as background\".", + "BACKGROUND_REMOVE": "Remove background" }, "VALIDATION": { "EMAIL_REQUIRED": "Email is required.", @@ -158,7 +179,9 @@ "TTB": "Time to beat (IGDB)", "TTB_HASTILY": "Story", "TTB_NORMALLY": "+ Extras", - "TTB_COMPLETELY": "100%" + "TTB_COMPLETELY": "100%", + "SET_BACKGROUND": "Use as background", + "REMOVE_BACKGROUND": "Remove background" }, "LANDING": { "STATS_TITLE": "Community stats", diff --git a/src/App.jsx b/src/App.jsx index 509dbe2..84b35e4 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -15,10 +15,13 @@ import PublicProfilePage from './pages/PublicProfilePage.jsx'; import AppHeader from './components/AppHeader.jsx'; import {Toaster} from '@/components/ui/sonner'; import {TooltipProvider} from '@/components/ui/tooltip'; +import PageBackground from './components/PageBackground.jsx'; +import {usePreferences} from './context/PreferencesContext.jsx'; function App() { const {t} = useTranslation(); const [isAuthenticated, setIsAuthenticated] = useState(false); + const {appearance, syncAppearance, resetAppearance} = usePreferences(); // Hooks for routing const navigate = useNavigate(); @@ -30,15 +33,23 @@ function App() { setIsAuthenticated(!!localStorage.getItem('tbp_jwt')); }, [location]); + // On login and on page load; the cached look shows until Auth answers. + useEffect(() => { + if (isAuthenticated) syncAppearance(); + }, [isAuthenticated, syncAppearance]); + const handleLogout = () => { localStorage.removeItem('tbp_jwt'); + resetAppearance(); setIsAuthenticated(false); navigate('/login'); }; return ( -
+ {/* isolate: the background layer sits behind the content but in front of this div's own background. */} +
+ {isAuthenticated && }
diff --git a/src/components/AppHeader.jsx b/src/components/AppHeader.jsx index f56e51a..35d686c 100644 --- a/src/components/AppHeader.jsx +++ b/src/components/AppHeader.jsx @@ -12,6 +12,7 @@ import { } from '@/components/ui/dropdown-menu'; import {usePreferences} from '@/context/PreferencesContext.jsx'; import {cn} from '@/lib/utils'; +import PageContainer from './PageContainer.jsx'; const NAV_ITEMS = [ {to: '/dashboard', labelKey: 'NAV.DASHBOARD', Icon: LayoutDashboard}, @@ -37,7 +38,7 @@ export default function AppHeader({isAuthenticated, onLogout}) { return (
-
+ @@ -95,7 +96,7 @@ export default function AppHeader({isAuthenticated, onLogout}) { )}
-
+ ); } diff --git a/src/components/PageBackground.jsx b/src/components/PageBackground.jsx new file mode 100644 index 0000000..a72fef5 --- /dev/null +++ b/src/components/PageBackground.jsx @@ -0,0 +1,21 @@ +import {useState} from 'react'; +import {coverUrl, heroUrl} from '../utils/games.js'; + +/** + * Game art behind the whole page, blurred and dimmed so text on it stays readable. Falls back to the store + * header for apps without library art. Give it `key={appid}`, so a new game starts with its library art again. + */ +export default function PageBackground({appid}) { + const [failures, setFailures] = useState(0); + + if (!appid || failures > 1) return null; + + return ( + + ); } diff --git a/src/pages/GameDetail.test.jsx b/src/pages/GameDetail.test.jsx index aa015c2..6925a5c 100644 --- a/src/pages/GameDetail.test.jsx +++ b/src/pages/GameDetail.test.jsx @@ -3,6 +3,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; import {MemoryRouter, Route, Routes} from 'react-router-dom'; import GameDetail from './GameDetail.jsx'; +import {PreferencesProvider} from '../context/PreferencesContext.jsx'; vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); @@ -13,9 +14,9 @@ const game = { }; const renderDetail = () => render( - + }/> - , + , ); beforeEach(() => { @@ -41,4 +42,18 @@ describe('GameDetail', () => { await waitFor(() => expect(screen.queryByRole('button', {name: 'GAME_DETAIL.SET_PLAYING'})).toBeNull()); expect(screen.getByLabelText('BACKLOG.NOTES_LABEL').value).toBe('my draft'); }); + + it('uses the game as background and can remove it again', async () => { + renderDetail(); + + fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BACKGROUND'})); + + const remove = await screen.findByRole('button', {name: 'GAME_DETAIL.REMOVE_BACKGROUND'}); + const [url, init] = fetch.mock.calls.find(([, call]) => call?.method === 'PUT'); + expect(url).toContain('/api/users/settings/appearance'); + expect(JSON.parse(init.body).backgroundAppid).toBe(220); + + fireEvent.click(remove); + expect(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BACKGROUND'})).toBeTruthy(); + }); }); diff --git a/src/pages/GameLibrary.jsx b/src/pages/GameLibrary.jsx index f8da05d..27c4192 100644 --- a/src/pages/GameLibrary.jsx +++ b/src/pages/GameLibrary.jsx @@ -18,6 +18,7 @@ import {Skeleton} from '@/components/ui/skeleton'; import GameCover from '../components/GameCover.jsx'; import LibraryTabs from '../components/LibraryTabs.jsx'; import GameStatusBadge from '../components/GameStatusBadge.jsx'; +import PageContainer from '../components/PageContainer.jsx'; const PAGE_SIZES = [20, 40, 80]; const DEFAULT_SIZE = 20; @@ -146,7 +147,7 @@ export default function GameLibrary() { const ready = !isLoading && !error; return ( -
+
@@ -292,6 +293,6 @@ export default function GameLibrary() {
)} -
+
); } diff --git a/src/pages/GameLibrary.test.jsx b/src/pages/GameLibrary.test.jsx index 6bb681c..d4de539 100644 --- a/src/pages/GameLibrary.test.jsx +++ b/src/pages/GameLibrary.test.jsx @@ -3,6 +3,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; import {MemoryRouter, Route, Routes, useLocation, useNavigate} from 'react-router-dom'; import GameLibrary from './GameLibrary.jsx'; +import {PreferencesProvider} from '../context/PreferencesContext.jsx'; vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('../hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})})); @@ -26,9 +27,9 @@ function renderLibrary(initialEntry = '/library') { return null; } render( - + }/> - , + , ); return { navigations: () => lib.navigations, diff --git a/src/pages/PublicProfilePage.jsx b/src/pages/PublicProfilePage.jsx index c8a0920..b64280c 100644 --- a/src/pages/PublicProfilePage.jsx +++ b/src/pages/PublicProfilePage.jsx @@ -9,6 +9,7 @@ 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 PageContainer from '../components/PageContainer.jsx'; /** * Public, no login. Auth returns a short-lived ticket for a public profile; Core returns the data for it. @@ -64,22 +65,22 @@ export default function PublicProfilePage() { if (error) { return ( -
+

{error}

{t('PROFILE.TO_START')}
-
+ ); } if (!library) { return ( -
+ -
+ ); } @@ -94,7 +95,7 @@ export default function PublicProfilePage() { : null; return ( -
+
@@ -174,6 +175,6 @@ export default function PublicProfilePage() { )} -
+ ); } diff --git a/src/pages/PublicProfilePage.test.jsx b/src/pages/PublicProfilePage.test.jsx index 377c9d4..f4fdc23 100644 --- a/src/pages/PublicProfilePage.test.jsx +++ b/src/pages/PublicProfilePage.test.jsx @@ -3,6 +3,7 @@ import {afterEach, describe, expect, it, vi} from 'vitest'; import {cleanup, 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'; vi.mock('react-i18next', () => import('../test/i18n-mock.js')); @@ -24,9 +25,9 @@ function mockBackends({profileStatus = 200, libraryStatus = 200} = {}) { } const renderPage = (name = 'Lobo') => render( - + }/> - , + , ); describe('PublicProfilePage', () => { diff --git a/src/pages/SettingsPage.jsx b/src/pages/SettingsPage.jsx index b89daf7..b603c9a 100644 --- a/src/pages/SettingsPage.jsx +++ b/src/pages/SettingsPage.jsx @@ -4,7 +4,7 @@ import {useTranslation} from 'react-i18next'; import {useForm} from 'react-hook-form'; import {zodResolver} from '@hookform/resolvers/zod'; -import {Copy, Gamepad2, Loader2, Save, SlidersHorizontal, UserRound} from 'lucide-react'; +import {Copy, Gamepad2, ImageOff, Loader2, Palette, Save, SlidersHorizontal, UserRound} from 'lucide-react'; import {toast} from 'sonner'; import {Button} from '@/components/ui/button'; @@ -18,6 +18,10 @@ import FormField from '../components/FormField.jsx'; import {settingsSchema} from '../utils/validation.js'; import {usePreferences} from '../context/PreferencesContext.jsx'; import useSessionExpired from '../hooks/useSessionExpired.js'; +import useSaveAppearance from '../hooks/useSaveAppearance.js'; +import {ACCENTS, CORNERS} from '../utils/appearance.js'; +import {coverUrl} from '../utils/games.js'; +import {cn} from '@/lib/utils'; const BACKEND_ERROR_KEY = { STEAM_ID_ALREADY_IN_USE: 'REGISTRATION.ERROR_STEAM_ID_IN_USE', @@ -39,7 +43,8 @@ export default function SettingsPage() { const {t, i18n} = useTranslation(); const navigate = useNavigate(); const expireSession = useSessionExpired(); - const {isDarkMode, setDarkMode, setLanguage} = usePreferences(); + const {isDarkMode, setDarkMode, setLanguage, appearance} = usePreferences(); + const saveAppearance = useSaveAppearance(); const [profile, setProfile] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -268,6 +273,68 @@ export default function SettingsPage() { + + + {t('SETTINGS.APPEARANCE')} + + +
+ +
+ {ACCENTS.map((accent) => ( +
+
+
+ +
+ {CORNERS.map((corners) => ( + + ))} +
+
+
+
+ +

{t('SETTINGS.WIDE_LAYOUT_HINT')}

+
+ saveAppearance({wideLayout})}/> +
+
+
+ + {!appearance.backgroundAppid && ( +

{t('SETTINGS.BACKGROUND_HINT')}

+ )} +
+ {appearance.backgroundAppid && ( +
+ { event.currentTarget.hidden = true; }} + className="h-10 w-20 rounded-md object-cover"/> + +
+ )} +
+
+
+ {t('SETTINGS.PROFILE')} diff --git a/src/pages/SettingsPage.test.jsx b/src/pages/SettingsPage.test.jsx index a7ca5a6..422f80c 100644 --- a/src/pages/SettingsPage.test.jsx +++ b/src/pages/SettingsPage.test.jsx @@ -74,3 +74,39 @@ describe('SettingsPage public profile', () => { await waitFor(() => expect(screen.getByRole('switch', {name: 'SETTINGS.PUBLIC_PROFILE'}).getAttribute('aria-checked')).toBe('false')); }); }); + +describe('SettingsPage appearance', () => { + beforeEach(() => localStorage.setItem('tbp_jwt', 'token')); + + afterEach(() => { + cleanup(); + localStorage.clear(); + }); + + const appearancePuts = () => fetch.mock.calls + .filter(([url, init]) => init?.method === 'PUT' && url.endsWith('/api/users/settings/appearance')) + .map(([, init]) => JSON.parse(init.body)); + + 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 waitFor(() => expect(appearancePuts()).toHaveLength(2)); + expect(appearancePuts()[1]).toEqual({accent: 'green', corners: 'round', wideLayout: false, backgroundAppid: null}); + 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'); + }); + + it('removes the background', async () => { + localStorage.setItem('tbp_appearance', JSON.stringify({backgroundAppid: 620})); + renderPage(profile()); + + fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.BACKGROUND_REMOVE'})); + + await waitFor(() => expect(appearancePuts()).toHaveLength(1)); + expect(appearancePuts()[0].backgroundAppid).toBeNull(); + expect(screen.getByText('SETTINGS.BACKGROUND_HINT')).toBeTruthy(); + }); +}); diff --git a/src/pages/WishlistPage.jsx b/src/pages/WishlistPage.jsx index 90589b1..440e884 100644 --- a/src/pages/WishlistPage.jsx +++ b/src/pages/WishlistPage.jsx @@ -10,6 +10,7 @@ import {Skeleton} from '@/components/ui/skeleton'; import GameCover from '../components/GameCover.jsx'; import LibraryTabs from '../components/LibraryTabs.jsx'; import useSessionExpired from '../hooks/useSessionExpired.js'; +import PageContainer from '../components/PageContainer.jsx'; const formatAdded = (instant, locale) => instant ? new Date(instant).toLocaleDateString(locale, {year: 'numeric', month: 'short', day: 'numeric'}) : null; @@ -59,7 +60,7 @@ export default function WishlistPage() { const onSaleCount = (games ?? []).filter((g) => g.discountPct > 0).length; return ( -
+
@@ -142,6 +143,6 @@ export default function WishlistPage() { )} -
+ ); } diff --git a/src/pages/WishlistPage.test.jsx b/src/pages/WishlistPage.test.jsx index f3025bf..44cea18 100644 --- a/src/pages/WishlistPage.test.jsx +++ b/src/pages/WishlistPage.test.jsx @@ -3,6 +3,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; import {MemoryRouter, Route, Routes, useLocation} from 'react-router-dom'; import WishlistPage from './WishlistPage.jsx'; +import {PreferencesProvider} from '../context/PreferencesContext.jsx'; vi.mock('react-i18next', () => import('../test/i18n-mock.js')); @@ -16,12 +17,12 @@ function LoginState() { } const renderPage = () => render( - + }/> }/> - , + , ); describe('WishlistPage', () => { diff --git a/src/style.css b/src/style.css index 35360e3..f4e0e84 100644 --- a/src/style.css +++ b/src/style.css @@ -48,6 +48,114 @@ --ring: oklch(0.541 0.281 293.009); } +/* + * Appearance presets (utils/appearance.js). Set as data attributes on ; the settings page also sets them on + * its preview buttons, so each button shows its own preset. Violet repeats the defaults above for that reason. + * Green and orange are too light for white text, so their dark buttons get dark text. + */ +[data-accent="violet"] { + --primary: oklch(0.541 0.281 293.009); + --primary-foreground: oklch(0.969 0.016 293.756); + --accent: oklch(0.943 0.029 294.588); + --accent-foreground: oklch(0.38 0.189 293.745); + --ring: oklch(0.606 0.25 292.717); +} +.dark[data-accent="violet"], +.dark [data-accent="violet"] { + --primary: oklch(0.606 0.25 292.717); + --primary-foreground: oklch(0.969 0.016 293.756); + --accent: oklch(0.283 0.141 291.089); + --accent-foreground: oklch(0.894 0.057 293.283); + --ring: oklch(0.541 0.281 293.009); +} +[data-accent="blue"] { + --primary: oklch(0.546 0.245 262.881); + --primary-foreground: oklch(0.97 0.014 254.604); + --accent: oklch(0.932 0.032 255.585); + --accent-foreground: oklch(0.424 0.199 265.638); + --ring: oklch(0.623 0.214 259.815); +} +.dark[data-accent="blue"], +.dark [data-accent="blue"] { + --primary: oklch(0.623 0.214 259.815); + --primary-foreground: oklch(0.97 0.014 254.604); + --accent: oklch(0.282 0.091 267.935); + --accent-foreground: oklch(0.882 0.059 254.128); + --ring: oklch(0.546 0.245 262.881); +} +[data-accent="green"] { + --primary: oklch(0.527 0.154 150.069); + --primary-foreground: oklch(0.982 0.018 155.826); + --accent: oklch(0.962 0.044 156.743); + --accent-foreground: oklch(0.448 0.119 151.328); + --ring: oklch(0.627 0.194 149.214); +} +.dark[data-accent="green"], +.dark [data-accent="green"] { + --primary: oklch(0.723 0.219 149.579); + --primary-foreground: oklch(0.266 0.065 152.934); + --accent: oklch(0.266 0.065 152.934); + --accent-foreground: oklch(0.925 0.084 155.995); + --ring: oklch(0.627 0.194 149.214); +} +[data-accent="orange"] { + --primary: oklch(0.553 0.195 38.402); + --primary-foreground: oklch(0.98 0.016 73.684); + --accent: oklch(0.954 0.038 75.164); + --accent-foreground: oklch(0.47 0.157 37.304); + --ring: oklch(0.646 0.222 41.116); +} +.dark[data-accent="orange"], +.dark [data-accent="orange"] { + --primary: oklch(0.705 0.213 47.604); + --primary-foreground: oklch(0.266 0.079 36.259); + --accent: oklch(0.266 0.079 36.259); + --accent-foreground: oklch(0.901 0.076 70.697); + --ring: oklch(0.646 0.222 41.116); +} +[data-accent="pink"] { + --primary: oklch(0.592 0.249 0.584); + --primary-foreground: oklch(0.971 0.014 343.198); + --accent: oklch(0.948 0.028 342.258); + --accent-foreground: oklch(0.459 0.187 3.815); + --ring: oklch(0.656 0.241 354.308); +} +.dark[data-accent="pink"], +.dark [data-accent="pink"] { + --primary: oklch(0.592 0.249 0.584); + --primary-foreground: oklch(0.971 0.014 343.198); + --accent: oklch(0.284 0.109 3.907); + --accent-foreground: oklch(0.899 0.061 343.231); + --ring: oklch(0.656 0.241 354.308); +} +[data-accent="red"] { + --primary: oklch(0.577 0.245 27.325); + --primary-foreground: oklch(0.971 0.013 17.38); + --accent: oklch(0.936 0.032 17.717); + --accent-foreground: oklch(0.444 0.177 26.899); + --ring: oklch(0.637 0.237 25.331); +} +.dark[data-accent="red"], +.dark [data-accent="red"] { + --primary: oklch(0.577 0.245 27.325); + --primary-foreground: oklch(0.971 0.013 17.38); + --accent: oklch(0.258 0.092 26.042); + --accent-foreground: oklch(0.885 0.062 18.334); + --ring: oklch(0.637 0.237 25.331); +} + +[data-corners="sharp"] { + --radius: 0.25rem; +} + +[data-corners="default"] { + --radius: 0.75rem; +} + +[data-corners="round"] { + --radius: 1.25rem; +} + @theme inline { --font-sans: "Inter Variable", ui-sans-serif, system-ui, sans-serif; --radius-sm: calc(var(--radius) - 4px); diff --git a/src/utils/appearance.js b/src/utils/appearance.js new file mode 100644 index 0000000..fcb2b19 --- /dev/null +++ b/src/utils/appearance.js @@ -0,0 +1,65 @@ +// Auth accepts only these names (AppearanceRequest); style.css has a preset for each. +export const ACCENTS = ['violet', 'blue', 'green', 'orange', 'pink', 'red']; +export const CORNERS = ['sharp', 'default', 'round']; + +export const DEFAULT_APPEARANCE = {accent: 'violet', corners: 'default', wideLayout: false, backgroundAppid: null}; + +const STORAGE_KEY = 'tbp_appearance'; +const REQUEST_TIMEOUT_MS = 10000; + +export const normalizeAppearance = (value) => ({ + accent: ACCENTS.includes(value?.accent) ? value.accent : DEFAULT_APPEARANCE.accent, + corners: CORNERS.includes(value?.corners) ? value.corners : DEFAULT_APPEARANCE.corners, + wideLayout: value?.wideLayout === true, + backgroundAppid: Number.isInteger(value?.backgroundAppid) && value.backgroundAppid > 0 ? value.backgroundAppid : null, +}); + +/** + * The last known appearance, so the first paint does not flash the default look before Auth answers. + */ +export const readCachedAppearance = () => { + try { + return normalizeAppearance(JSON.parse(localStorage.getItem(STORAGE_KEY))); + } catch { + return DEFAULT_APPEARANCE; + } +}; + +export const cacheAppearance = (appearance) => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(appearance)); + } catch { + // Private mode or a full storage: the look is only not cached. + } +}; + +export const applyAppearance = (appearance, root = document.documentElement) => { + root.dataset.accent = appearance.accent; + root.dataset.corners = appearance.corners; +}; + +/** + * PUT /api/users/settings/appearance on Auth. The caller handles the response status. + */ +export const saveAppearance = (appearance) => + fetch(`${import.meta.env.VITE_API_URL}/api/users/settings/appearance`, { + method: 'PUT', + headers: {'Authorization': `Bearer ${localStorage.getItem('tbp_jwt')}`, 'Content-Type': 'application/json'}, + body: JSON.stringify(appearance), + // Saves run one after another; a hung request must not hold back the next ones. + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + +/** + * GET /api/users/me on Auth, only for its appearance. Null when there is no answer to use. + */ +export const loadAppearance = async () => { + const response = await fetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, { + headers: {'Authorization': `Bearer ${localStorage.getItem('tbp_jwt')}`}, + // Saves wait for this load. + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) return null; + const data = await response.json(); + return data.settings?.appearance ? normalizeAppearance(data.settings.appearance) : null; +}; diff --git a/src/utils/appearance.test.js b/src/utils/appearance.test.js new file mode 100644 index 0000000..c7c57b4 --- /dev/null +++ b/src/utils/appearance.test.js @@ -0,0 +1,52 @@ +// @vitest-environment jsdom +import {afterEach, describe, expect, it} from 'vitest'; +import { + applyAppearance, + cacheAppearance, + DEFAULT_APPEARANCE, + normalizeAppearance, + readCachedAppearance, +} from './appearance.js'; + +describe('normalizeAppearance', () => { + it('keeps known values', () => { + const appearance = {accent: 'blue', corners: 'round', wideLayout: true, backgroundAppid: 620}; + + expect(normalizeAppearance(appearance)).toEqual(appearance); + }); + + it('replaces unknown or missing values with the defaults', () => { + expect(normalizeAppearance({accent: 'red;x', corners: 'pointy', wideLayout: 'yes', backgroundAppid: -1})) + .toEqual(DEFAULT_APPEARANCE); + expect(normalizeAppearance(null)).toEqual(DEFAULT_APPEARANCE); + expect(normalizeAppearance({backgroundAppid: 1.5})).toEqual(DEFAULT_APPEARANCE); + }); +}); + +describe('appearance cache', () => { + afterEach(() => localStorage.clear()); + + it('reads back what was cached', () => { + cacheAppearance({accent: 'green', corners: 'sharp', wideLayout: false, backgroundAppid: null}); + + expect(readCachedAppearance()).toEqual({accent: 'green', corners: 'sharp', wideLayout: false, backgroundAppid: null}); + }); + + it('falls back to the defaults for a broken or empty cache', () => { + expect(readCachedAppearance()).toEqual(DEFAULT_APPEARANCE); + + localStorage.setItem('tbp_appearance', '{broken'); + expect(readCachedAppearance()).toEqual(DEFAULT_APPEARANCE); + }); +}); + +describe('applyAppearance', () => { + it('sets the presets as data attributes for the CSS', () => { + const root = document.createElement('html'); + + applyAppearance({accent: 'orange', corners: 'round', wideLayout: true, backgroundAppid: 620}, root); + + expect(root.dataset.accent).toBe('orange'); + expect(root.dataset.corners).toBe('round'); + }); +}); diff --git a/src/utils/games.js b/src/utils/games.js index 34cd972..c43a6f6 100644 --- a/src/utils/games.js +++ b/src/utils/games.js @@ -12,3 +12,6 @@ export const formatPlaytime = (minutes) => { // 460x215 store header. Not every app has one; callers need an onError fallback. export const coverUrl = (appid) => `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/header.jpg`; + +// 1920x620 library art. Older apps have none; callers need an onError fallback. +export const heroUrl = (appid) => `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/library_hero.jpg`;