Compare commits

...
4 Commits
Author SHA1 Message Date
lobo 249973dd69 feat(points): apply worn effects on covers, details and backgrounds
Build, Test & Push Frontend / docker (push) Successful in 27s
Build, Test & Push Frontend / quality-check (push) Successful in 28s
2026-10-01 15:09:25 +02:00
lobo dd45c0b5f6 feat(points): add effect engines and effects shop 2026-10-01 15:09:25 +02:00
lobo 2ec5833f29 feat(settings): reduce animations switch 2026-10-01 15:09:25 +02:00
lobo fa0857f55d feat(broadcasts): add admin broadcast page and user opt-out switch
Admin page /admin/broadcasts to write, test and send broadcasts in both
languages; confirms the recipient count before send. History shows the
counts. A new switch in settings lets users opt out of platform mails;
account mails always come. Subject and body are plain text only.
2026-10-01 14:12:34 +02:00
46 changed files with 1744 additions and 62 deletions

No files matched your search

+9 -4
View File
@@ -32,8 +32,10 @@ To Be Played is split into three repositories:
years of full-time work, and its oldest unplayed game; most played and most loyal games; top genres and moods;
trips to the moon, the longest gaming day, games started but under one hour; finished games per year; games per platform.
- **Pile Points** (`/points`): points for playing games off your pile (start, finish by time to beat, 100 %, month and
streak bonuses; Core checks proof of play), spent in a small shop on an avatar border, a badge, a premium accent and a
banner frame, shown on the dashboard and the public profile. The balance is in the header.
streak bonuses; Core checks proof of play), spent in a small shop on an avatar border, a badge, a premium accent, a
banner frame, an effect for 100 % games and an animated page or banner background (CSS and SVG, in `effects.css`),
shown on the dashboard and the public profile. The balance is in the header. Every effect stands still with the
system setting "reduce motion" or the "Reduce animations" switch in the settings.
- **Landing page**: next to the community numbers and the top games, three blocks: games per platform, top genres and
finished games per year of all users.
- **Library**: status filters, "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest
@@ -71,7 +73,7 @@ To Be Played is split into three repositories:
every achievement is unlocked.
- **Confetti**: opening a finished game plays a small confetti burst, a finished game with every achievement a big
one; setting the status to finished plays it at once. Logo and accent colors, a canvas of our own (no library),
nothing with "reduce motion".
nothing with "reduce motion" (system or the settings switch).
- **Finished?**: Core marks a game as finished by itself when the ending achievement is unlocked and the status was
never set by hand. Otherwise the game page asks: "You unlocked the ending, finished?" or "You played longer than
most players need, finished?", with "Mark as finished" and "Not yet".
@@ -85,7 +87,7 @@ To Be Played is split into three repositories:
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.
- **Appearance**: accent color, corner style, wide layout, reduce animations, page background and banner from your own game art.
Saved in Auth, so it follows you to every device.
- **Language**: kept in the browser and saved in Auth; after the login Auth's language wins when one is saved. Dark
mode stays per device. The registration sends the browser language (`de` for a German browser, else `en`): Auth
@@ -107,6 +109,9 @@ To Be Played is split into three repositories:
- **What's new**: a dialog on the dashboard shows the news entries the user has not seen yet, newest first. "Got it"
marks them seen in Auth; closing the dialog any other way shows them again on the next dashboard visit. `/whats-new`
(menu) lists all entries and marks them seen. Admins publish, preview, edit and delete entries on `/admin/news`.
- **Broadcast mail**: admins write a mail in both languages on `/admin/broadcasts`, send it to all active users or to
picked ones, send a test to themselves, confirm with the real recipient count, and see the history with the counts.
Users turn platform mails off in the settings; account mails always come.
- **Welcome page**: `/welcome` opens once after the first login (Auth `settings.welcomeSeenAt` is null; only the
dashboard redirects, deep links stay). Feature cards with links, two next steps (Steam, import) with checks from
`/me` and the library summary. "Let's go" and "Skip" stamp it in Auth and open the dashboard. The settings page
+60 -3
View File
@@ -89,6 +89,8 @@
},
"WIDE_LAYOUT": "Breites Layout",
"WIDE_LAYOUT_HINT": "Nutzt mehr Platz auf breiten Bildschirmen.",
"REDUCE_MOTION": "Animationen reduzieren",
"REDUCE_MOTION_HINT": "Shop-Effekte und Konfetti stehen still, auch auf fremden Profilen.",
"BACKGROUND": "Hintergrund",
"BACKGROUND_HINT": "Öffne ein Spiel in deiner Bibliothek und wähle „Als Hintergrund“.",
"BACKGROUND_REMOVE": "Hintergrund entfernen",
@@ -130,7 +132,9 @@
"PROFILE_TILES_HINT": "Wähle, was dein Profil zeigt und wo: öffne dein Profil und klicke auf \"Anpassen\".",
"FAVORITE_PLATFORM": "Lieblingsplattform",
"FAVORITE_PLATFORM_HINT": "Wird zuerst gewählt, wenn du ein Spiel für mehrere Plattformen hinzufügst.",
"FAVORITE_PLATFORM_NONE": "Keine"
"FAVORITE_PLATFORM_NONE": "Keine",
"PLATFORM_MAILS": "Mails von der Plattform",
"PLATFORM_MAILS_HINT": "Neuigkeiten und Ankündigungen per Mail. Mails zu deinem Konto kommen immer."
},
"VALIDATION": {
"EMAIL_REQUIRED": "Email ist erforderlich.",
@@ -304,6 +308,7 @@
"ADMIN_HINTS": "Hinweise",
"WHATS_NEW": "Neuigkeiten",
"ADMIN_NEWS": "Neuigkeiten verwalten",
"ADMIN_BROADCASTS": "Rundmail",
"PENDING_REQUESTS_one": "{{count}} Registrierung wartet",
"PENDING_REQUESTS_other": "{{count}} Registrierungen warten"
},
@@ -691,6 +696,35 @@
"LOAD_ERROR": "Die Einträge konnten nicht geladen werden.",
"NO_ACCESS": "Nur Admins können diese Seite öffnen."
},
"ADMIN_BROADCAST": {
"NEW": "Neue Rundmail",
"SUBJECT_EN": "Betreff (Englisch)",
"SUBJECT_DE": "Betreff (Deutsch)",
"BODY_EN": "Text (Englisch)",
"BODY_DE": "Text (Deutsch)",
"TEXT_HELP": "Nur Text, in beiden Sprachen. Betreff höchstens 150, Text höchstens 5000 Zeichen. Eine Fußzeile mit dem Link zum Abschalten wird angehängt.",
"RECIPIENTS": "Empfänger",
"ALL": "Alle aktiven Nutzer",
"PICKED": "Ausgewählte Nutzer",
"SEARCH": "E-Mail oder Name suchen",
"PICKED_COUNT": "{{count}} ausgewählt",
"REMOVE": "Entfernen",
"TEST": "Test an mich senden",
"TEST_SENT": "Testmail für dich eingereiht.",
"SEND": "Senden...",
"CONFIRM_TITLE": "Diese Mail senden?",
"CONFIRM_TEXT": "An {{count}} Nutzer senden? Das kann nicht rückgängig gemacht werden.",
"CONFIRM": "Senden",
"CANCEL": "Abbrechen",
"NO_RECIPIENTS": "Kein Empfänger: niemand ist aktiv und hat Mails an.",
"SENT": "Mail für {{count}} Nutzer eingereiht.",
"ERROR": "Die Aktion ist fehlgeschlagen.",
"LIST": "Gesendete Mails",
"COUNTS": "{{recipients}} Empfänger, {{sent}} gesendet, {{failed}} fehlgeschlagen",
"EMPTY": "Noch keine Mails gesendet.",
"LOAD_ERROR": "Die Mails konnten nicht geladen werden.",
"NO_ACCESS": "Nur Admins können diese Seite öffnen."
},
"TILES": {
"CUSTOMIZE": "Anpassen",
"EDIT_HINT": "Verschiebe, vergrößere oder verstecke die Kacheln. Gespeichert wird erst mit \"Fertig\".",
@@ -826,7 +860,10 @@
"AVATAR_BORDER": "Avatarrahmen",
"BADGE": "Abzeichen",
"ACCENT": "Akzent",
"BANNER_FRAME": "Bannerrahmen"
"BANNER_FRAME": "Bannerrahmen",
"COMPLETED_EFFECT": "Effekt für 100 %",
"PAGE_BACKGROUND": "Seitenhintergrund",
"BANNER_BACKGROUND": "Bannerhintergrund"
},
"ITEMS": {
"BORDER_GOLD": "Goldener Avatarrahmen",
@@ -836,7 +873,27 @@
"ACCENT_GOLD": "Akzent Gold",
"ACCENT_TEAL": "Akzent Türkis",
"FRAME_GOLD": "Goldener Bannerrahmen",
"FRAME_NEON": "Neon-Bannerrahmen"
"FRAME_NEON": "Neon-Bannerrahmen",
"FX_GOLD_SHINE": "Goldglanz",
"FX_NEON_PULSE": "Neon-Puls",
"FX_HOLO": "Holo-Folie",
"FX_EMBER": "Glut",
"BG_TRIANGLES": "Animierter Hintergrund: Dreiecke",
"BN_TRIANGLES": "Animiertes Banner: Dreiecke",
"BG_STARFIELD": "Animierter Hintergrund: Sternenhimmel",
"BN_STARFIELD": "Animiertes Banner: Sternenhimmel",
"BG_PIXELS": "Animierter Hintergrund: Pixel",
"BN_PIXELS": "Animiertes Banner: Pixel",
"BG_AURORA": "Animierter Hintergrund: Polarlicht",
"BN_AURORA": "Animiertes Banner: Polarlicht",
"BG_MESH": "Animierter Hintergrund: Farbverlauf",
"BN_MESH": "Animiertes Banner: Farbverlauf",
"BG_GRID": "Animierter Hintergrund: Synthwave-Gitter",
"BN_GRID": "Animiertes Banner: Synthwave-Gitter",
"BG_WAVES": "Animierter Hintergrund: Wellen",
"BN_WAVES": "Animiertes Banner: Wellen",
"BG_BOKEH": "Animierter Hintergrund: Bokeh-Lichter",
"BN_BOKEH": "Animiertes Banner: Bokeh-Lichter"
},
"KINDS": {
"STARTED": "Gestartet",
+60 -3
View File
@@ -89,6 +89,8 @@
},
"WIDE_LAYOUT": "Wide layout",
"WIDE_LAYOUT_HINT": "Uses more of a wide screen.",
"REDUCE_MOTION": "Reduce animations",
"REDUCE_MOTION_HINT": "Shop effects and confetti stand still, also on other profiles.",
"BACKGROUND": "Background",
"BACKGROUND_HINT": "Open a game in your library and choose \"Use as background\".",
"BACKGROUND_REMOVE": "Remove background",
@@ -130,7 +132,9 @@
"PROFILE_TILES_HINT": "Choose what your profile shows, and where: open your profile and click \"Customize\".",
"FAVORITE_PLATFORM": "Favorite platform",
"FAVORITE_PLATFORM_HINT": "Selected first when you add a game on more than one platform.",
"FAVORITE_PLATFORM_NONE": "None"
"FAVORITE_PLATFORM_NONE": "None",
"PLATFORM_MAILS": "Mails from the platform",
"PLATFORM_MAILS_HINT": "News and announcements by mail. Mails about your account always come."
},
"VALIDATION": {
"EMAIL_REQUIRED": "Email is required.",
@@ -304,6 +308,7 @@
"ADMIN_HINTS": "Hints",
"WHATS_NEW": "What's new",
"ADMIN_NEWS": "News",
"ADMIN_BROADCASTS": "Mail",
"PENDING_REQUESTS_one": "{{count}} registration waits",
"PENDING_REQUESTS_other": "{{count}} registrations wait"
},
@@ -691,6 +696,35 @@
"LOAD_ERROR": "The entries could not be loaded.",
"NO_ACCESS": "Only admins can open this page."
},
"ADMIN_BROADCAST": {
"NEW": "New mail",
"SUBJECT_EN": "Subject (English)",
"SUBJECT_DE": "Subject (German)",
"BODY_EN": "Text (English)",
"BODY_DE": "Text (German)",
"TEXT_HELP": "Plain text, both languages. Subject max 150, text max 5000 characters. A footer with the opt-out link is added.",
"RECIPIENTS": "Recipients",
"ALL": "All active users",
"PICKED": "Picked users",
"SEARCH": "Search email or name",
"PICKED_COUNT": "{{count}} picked",
"REMOVE": "Remove",
"TEST": "Send test to me",
"TEST_SENT": "Test mail queued for you.",
"SEND": "Send...",
"CONFIRM_TITLE": "Send this mail?",
"CONFIRM_TEXT": "Send to {{count}} users? This cannot be undone.",
"CONFIRM": "Send",
"CANCEL": "Cancel",
"NO_RECIPIENTS": "No recipient: nobody active with mails on.",
"SENT": "Mail queued for {{count}} users.",
"ERROR": "The action failed.",
"LIST": "Sent mails",
"COUNTS": "{{recipients}} recipients, {{sent}} sent, {{failed}} failed",
"EMPTY": "No mails sent yet.",
"LOAD_ERROR": "The mails could not be loaded.",
"NO_ACCESS": "Only admins can open this page."
},
"TILES": {
"CUSTOMIZE": "Customize",
"EDIT_HINT": "Move, resize or hide the tiles. Nothing is saved before \"Done\".",
@@ -826,7 +860,10 @@
"AVATAR_BORDER": "Avatar border",
"BADGE": "Badge",
"ACCENT": "Accent",
"BANNER_FRAME": "Banner frame"
"BANNER_FRAME": "Banner frame",
"COMPLETED_EFFECT": "Completed effect",
"PAGE_BACKGROUND": "Page background",
"BANNER_BACKGROUND": "Banner background"
},
"ITEMS": {
"BORDER_GOLD": "Golden avatar border",
@@ -836,7 +873,27 @@
"ACCENT_GOLD": "Gold accent",
"ACCENT_TEAL": "Teal accent",
"FRAME_GOLD": "Golden banner frame",
"FRAME_NEON": "Neon banner frame"
"FRAME_NEON": "Neon banner frame",
"FX_GOLD_SHINE": "Gold shine",
"FX_NEON_PULSE": "Neon pulse",
"FX_HOLO": "Holographic foil",
"FX_EMBER": "Embers",
"BG_TRIANGLES": "Animated background: triangles",
"BN_TRIANGLES": "Animated banner: triangles",
"BG_STARFIELD": "Animated background: starfield",
"BN_STARFIELD": "Animated banner: starfield",
"BG_PIXELS": "Animated background: pixels",
"BN_PIXELS": "Animated banner: pixels",
"BG_AURORA": "Animated background: aurora",
"BN_AURORA": "Animated banner: aurora",
"BG_MESH": "Animated background: mesh gradient",
"BN_MESH": "Animated banner: mesh gradient",
"BG_GRID": "Animated background: synthwave grid",
"BN_GRID": "Animated banner: synthwave grid",
"BG_WAVES": "Animated background: waves",
"BN_WAVES": "Animated banner: waves",
"BG_BOKEH": "Animated background: bokeh",
"BN_BOKEH": "Animated banner: bokeh"
},
"KINDS": {
"STARTED": "Started",
+7 -2
View File
@@ -9,6 +9,7 @@ import Dashboard from "./pages/Dashboard.jsx";
import RegistrationPage from './pages/RegistrationPage.jsx';
import SettingsPage from './pages/SettingsPage.jsx';
import AdminHintsPage from './pages/AdminHintsPage.jsx';
import AdminBroadcastPage from './pages/AdminBroadcastPage.jsx';
import AdminNewsPage from './pages/AdminNewsPage.jsx';
import WhatsNewPage from './pages/WhatsNewPage.jsx';
import WelcomePage from './pages/WelcomePage.jsx';
@@ -28,6 +29,7 @@ import {Toaster} from '@/components/ui/sonner';
import {TooltipProvider} from '@/components/ui/tooltip';
import PageBackground from './components/PageBackground.jsx';
import {usePreferences} from './context/PreferencesContext.jsx';
import {backgroundEffect} from './utils/rewards.js';
import {clearSession} from './utils/auth.js';
import useSession from './hooks/useSession.js';
import {UnsavedChangesGuard} from './hooks/useUnsavedChanges.js';
@@ -36,7 +38,7 @@ import {logout} from './utils/api.js';
function App() {
const {t} = useTranslation();
const {active: isAuthenticated} = useSession();
const {appearance, syncPreferences} = usePreferences();
const {appearance, equipped, syncPreferences} = usePreferences();
// Hooks for routing
const navigate = useNavigate();
@@ -59,7 +61,9 @@ function App() {
{/* isolate: the background layer sits behind the content but in front of this div's own background. */}
<div className="isolate min-h-screen flex flex-col bg-background text-foreground font-sans">
{/* A public profile shows the owner's background instead. */}
{isAuthenticated && !location.pathname.startsWith('/u/') && <PageBackground appid={appearance.backgroundAppid}/>}
{isAuthenticated && !location.pathname.startsWith('/u/') && (
<PageBackground appid={appearance.backgroundAppid} effect={backgroundEffect(equipped)}/>
)}
<AppHeader isAuthenticated={isAuthenticated} onLogout={handleLogout}/>
<main className="grow flex flex-col">
@@ -82,6 +86,7 @@ function App() {
<Route path="/admin/users" element={<AdminUsersPage/>}/>
<Route path="/admin/hints" element={<AdminHintsPage/>}/>
<Route path="/admin/news" element={<AdminNewsPage/>}/>
<Route path="/admin/broadcasts" element={<AdminBroadcastPage/>}/>
</Route>
<Route path="/u/:name" element={<PublicProfilePage/>}/>
<Route path="/impressum" element={<ImprintPage/>}/>
+4 -1
View File
@@ -1,7 +1,7 @@
import {useEffect, useState} from 'react';
import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {ChartColumn, Coins, Dices, Eye, Languages, LayoutDashboard, Library, LogOut, Megaphone, Moon, Newspaper, Settings, Shield, Sun} from 'lucide-react';
import {ChartColumn, Coins, Dices, Eye, Languages, LayoutDashboard, Library, LogOut, Mail, Megaphone, Moon, Newspaper, Settings, Shield, Sun} from 'lucide-react';
import {toast} from 'sonner';
import {Badge} from '@/components/ui/badge';
@@ -169,6 +169,9 @@ export default function AppHeader({isAuthenticated, onLogout}) {
<DropdownMenuItem asChild>
<Link to="/admin/news"><Newspaper/>{t('NAV.ADMIN_NEWS')}</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/admin/broadcasts"><Mail/>{t('NAV.ADMIN_BROADCASTS')}</Link>
</DropdownMenuItem>
</>
)}
{isAdmin && (
+3
View File
@@ -55,6 +55,7 @@ describe('AppHeader', () => {
expect(await screen.findByText('NAV.ADMIN')).toBeTruthy();
expect(screen.getByText('NAV.ADMIN_NEWS')).toBeTruthy();
expect(screen.getByText('NAV.ADMIN_BROADCASTS')).toBeTruthy();
expect(screen.getByText('NAV.VIEW_AS.MENU')).toBeTruthy();
expect(screen.queryByText('NAV.VIEW_AS.BADGE')).toBeNull();
expect(screen.queryByLabelText('NAV.PENDING_REQUESTS:0')).toBeNull();
@@ -84,6 +85,7 @@ describe('AppHeader', () => {
openMenu();
expect(await screen.findByText('NAV.VIEW_AS.MENU')).toBeTruthy();
expect(screen.queryByText('NAV.ADMIN')).toBeNull();
expect(screen.queryByText('NAV.ADMIN_BROADCASTS')).toBeNull();
});
it('offers nothing of that to a user', async () => {
@@ -96,6 +98,7 @@ describe('AppHeader', () => {
expect(screen.getByText('NAV.WHATS_NEW')).toBeTruthy();
expect(screen.queryByText('NAV.ADMIN')).toBeNull();
expect(screen.queryByText('NAV.ADMIN_NEWS')).toBeNull();
expect(screen.queryByText('NAV.ADMIN_BROADCASTS')).toBeNull();
expect(screen.queryByText('NAV.VIEW_AS.MENU')).toBeNull();
});
});
+8
View File
@@ -31,6 +31,14 @@ export default function AppearanceCard() {
<Switch id="wideLayout" checked={appearance.wideLayout}
onCheckedChange={(wideLayout) => saveAppearance({wideLayout})}/>
</div>
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<Label htmlFor="reduceMotion">{t('SETTINGS.REDUCE_MOTION')}</Label>
<p className="text-xs text-muted-foreground">{t('SETTINGS.REDUCE_MOTION_HINT')}</p>
</div>
<Switch id="reduceMotion" checked={appearance.reduceMotion}
onCheckedChange={(reduceMotion) => saveAppearance({reduceMotion})}/>
</div>
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<Label id="background-label">{t('SETTINGS.BACKGROUND')}</Label>
+2 -3
View File
@@ -1,6 +1,7 @@
import {useEffect, useRef, useState} from 'react';
import {LOGO_COLORS} from './Logo.jsx';
import {reduceMotion} from '../utils/appearance.js';
const DURATION_MS = 2800;
const PIECES = {small: 70, big: 240};
@@ -8,8 +9,6 @@ const GRAVITY = 0.12;
// The speeds are per frame at 60 Hz; a faster screen moves the same distance in the same time.
const FRAME_MS = 1000 / 60;
const reducedMotion = () => window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
// The logo colors of the chosen accent and the accent itself.
function colors() {
const style = getComputedStyle(document.documentElement);
@@ -48,7 +47,7 @@ function pieces(size, width, height) {
*/
export default function Confetti({size, onDone}) {
const canvasRef = useRef(null);
const [reduce] = useState(reducedMotion);
const [reduce] = useState(reduceMotion);
// In a ref: a new function from a re-render must not restart the animation, and the latest one runs at the end.
const onDoneRef = useRef(onDone);
onDoneRef.current = onDone;
+9
View File
@@ -34,4 +34,13 @@ describe('Confetti', () => {
expect(container.querySelector('canvas')).toBeNull();
});
it('draws nothing when the reduce animations setting is on', () => {
reducedMotion(false);
document.documentElement.setAttribute('data-reduce-motion', '');
const {container} = render(<Confetti size="small"/>);
document.documentElement.removeAttribute('data-reduce-motion');
expect(container.querySelector('canvas')).toBeNull();
});
});
+2 -2
View File
@@ -11,7 +11,7 @@ import PointsBadge from './PointsBadge.jsx';
import SteamAvatar from './SteamAvatar.jsx';
import SyncStatusBadge from './SyncStatusBadge.jsx';
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
import {lookClass} from '../utils/rewards.js';
import {bannerEffect, lookClass} from '../utils/rewards.js';
export default function DashboardWelcomeCard({bannerAppid, avatarUrl, sync, forceSync, editing, onCustomize, equipped}) {
const {t} = useTranslation();
@@ -23,7 +23,7 @@ export default function DashboardWelcomeCard({bannerAppid, avatarUrl, sync, forc
<Card className={cn('relative overflow-hidden p-6 md:p-8 flex flex-col md:flex-row md:items-center md:justify-between gap-6',
lookClass(equipped, 'BANNER_FRAME'))}>
{/* A picked game, else the game played most in the last two weeks. */}
<HeroBanner appid={bannerAppid}/>
<HeroBanner appid={bannerAppid} effect={bannerEffect(equipped)}/>
<div className="relative flex min-w-0 items-center gap-4">
{/* The slot is there before the avatar loads, so the title does not jump. */}
{hasSteamId && (
+18 -2
View File
@@ -12,10 +12,10 @@ afterEach(() => {
localStorage.clear();
});
const renderCard = async (equipped) => {
const renderCard = async (equipped, bannerAppid) => {
localStorage.setItem('tbp_session', '1');
fetchRoutes({'GET /api/users/me': json(200, {displayName: 'Lobo', steamId: '1'}), 'GET /api/library/hero-art': json(200, {})});
renderWithProviders(<DashboardWelcomeCard sync={{}} forceSync={{}} equipped={equipped}/>);
renderWithProviders(<DashboardWelcomeCard bannerAppid={bannerAppid} sync={{}} forceSync={{}} equipped={equipped}/>);
await screen.findByText(/Lobo/);
};
@@ -29,6 +29,22 @@ describe('DashboardWelcomeCard looks', () => {
expect(screen.getByText('POINTS.ITEMS.BADGE_COMPLETIONIST')).toBeTruthy();
});
it('shows a worn banner background instead of the game art', async () => {
await renderCard({BANNER_BACKGROUND: 'BN_GRID'}, 620);
const card = screen.getByText(/Lobo/).closest('[data-slot="card"]');
expect(card.querySelector('[data-effect="grid"]')).not.toBeNull();
expect(card.querySelector('img[src*="library_hero"]')).toBeNull();
});
it('shows the game art without a worn banner background', async () => {
await renderCard({}, 620);
const card = screen.getByText(/Lobo/).closest('[data-slot="card"]');
expect(card.querySelector('[data-effect]')).toBeNull();
expect(card.querySelector('img[src*="library_hero"]')).not.toBeNull();
});
it('keeps the default avatar ring without a worn border', async () => {
await renderCard({});
+84
View File
@@ -0,0 +1,84 @@
import {cn} from '@/lib/utils';
export const EFFECTS = ['triangles', 'starfield', 'pixels', 'aurora', 'mesh', 'grid', 'waves', 'bokeh'];
const range = (count) => Array.from({length: count}, (_, i) => i);
// Three groups of eight triangles on a 1600x900 canvas; a fixed pattern, so the picture is the same on every visit.
const TRIANGLES = range(3).map((group) => range(8).map((i) => {
const x = (i * 211 + group * 97) % 1600;
const y = (i * 137 + group * 263) % 900;
const size = 80 + ((i * 53 + group * 29) % 90);
return {
points: `${x},${y - size} ${x - size * 0.87},${y + size / 2} ${x + size * 0.87},${y + size / 2}`,
rotate: `rotate(${i * 40} ${x} ${y})`,
opacity: 0.06 + ((i + group) % 4) * 0.04,
};
}));
// One period is 1600 units; two periods make the band 2 widths wide, so a move of one width loops without a jump.
const WAVE = 'M0 100 C200 30 600 30 800 100 C1000 170 1400 170 1600 100 C1800 30 2200 30 2400 100 '
+ 'C2600 170 3000 170 3200 100 L3200 200 L0 200 Z';
/**
* The markup of an animated page or banner background (CSS in effects.css). Fills its parent: pass `absolute inset-0`
* or `fixed inset-0`. Nothing here is read by a screen reader.
*
* @param effect one of EFFECTS; anything else renders nothing
*/
export default function EffectBackground({effect, className}) {
if (!EFFECTS.includes(effect)) return null;
return (
<div aria-hidden="true" data-effect={effect}
className={cn('fx fx-bg pointer-events-none overflow-hidden', `fx-bg-${effect}`, className)}>
{shapes(effect)}
</div>
);
}
function shapes(effect) {
switch (effect) {
case 'triangles':
return (
<svg className="size-full" viewBox="0 0 1600 900" preserveAspectRatio="xMidYMid slice">
{TRIANGLES.map((triangles, group) => (
<g key={group} fill="currentColor" className={group === 1 ? 'text-foreground' : undefined}>
{triangles.map((triangle) => (
<polygon key={triangle.points} points={triangle.points} transform={triangle.rotate}
opacity={triangle.opacity}/>
))}
</g>
))}
</svg>
);
case 'starfield':
return range(9).map((i) => <span key={i}/>);
case 'pixels':
return range(12).map((i) => (
<span key={i} style={{
'--x': (i * 37) % 100, '--y': (i * 53 + 11) % 90,
'--dur': `${25 + ((i * 7) % 16)}s`, '--delay': `${-i * 3.1}s`,
}}/>
));
case 'aurora':
return range(2).map((i) => <div key={i}/>);
case 'mesh':
return range(4).map((i) => <div key={i}/>);
case 'grid':
return range(2).map((i) => <div key={i}/>);
case 'waves':
return range(3).map((i) => (
<svg key={i} viewBox="0 0 3200 200" preserveAspectRatio="none">
<path d={WAVE} fill="currentColor"/>
</svg>
));
case 'bokeh':
return range(10).map((i) => (
<span key={i} style={{
'--x': (i * 29 + 7) % 90, '--y': (i * 47 + 10) % 90, '--s': 8 + ((i * 13) % 33),
'--dur': `${30 + ((i * 5) % 21)}s`, '--delay': `${-i * 4.3}s`,
}}/>
));
}
}
+26
View File
@@ -0,0 +1,26 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it} from 'vitest';
import {cleanup, render} from '@testing-library/react';
import EffectBackground, {EFFECTS} from './EffectBackground.jsx';
afterEach(cleanup);
describe('EffectBackground', () => {
it.each(EFFECTS)('renders %s hidden from screen readers with its engine class and shapes', (effect) => {
const {container} = render(<EffectBackground effect={effect} className="absolute inset-0"/>);
const root = container.querySelector('[data-effect]');
expect(root.dataset.effect).toBe(effect);
expect(root.getAttribute('aria-hidden')).toBe('true');
expect(root.classList.contains(`fx-bg-${effect}`)).toBe(true);
expect(root.classList.contains('fx')).toBe(true);
expect(root.classList.contains('absolute')).toBe(true);
expect(root.children.length).toBeGreaterThan(0);
});
it('renders nothing for an unknown effect', () => {
const {container} = render(<EffectBackground effect="matrix"/>);
expect(container.firstChild).toBeNull();
});
});
+8 -3
View File
@@ -1,23 +1,28 @@
import {useState} from 'react';
import {useRef, useState} from 'react';
import {Gamepad2} from 'lucide-react';
import {cn} from '@/lib/utils';
import {coverUrl} from '@/utils/games.js';
import {COVER, reportBrokenImage} from '@/utils/brokenImages.js';
import useInView from '../hooks/useInView.js';
/**
* Steam store header (460x215). Falls back to a neutral tile when the app has no header image.
*
* @param imageUrl a game added by hand from IGDB, or a Steam header Core repaired; wins over the fixed Steam header
* @param fx the classes of a completed effect (utils/rewards.js coverEffect); it holds still while the cover is off screen
*/
export default function GameCover({appid, imageUrl, className}) {
export default function GameCover({appid, imageUrl, fx, className}) {
const src = imageUrl ?? coverUrl(appid);
// Per URL: a reused cover (e.g. the next suggestion) loads and reports its own picture.
const [failedSrc, setFailedSrc] = useState(null);
const failed = failedSrc === src;
const ref = useRef(null);
const inView = useInView(ref, !!fx);
return (
<div className={cn('aspect-[460/215] overflow-hidden rounded-md bg-muted', className)}>
<div ref={ref}
className={cn('relative aspect-[460/215] overflow-hidden rounded-md bg-muted', fx, fx && !inView && 'fx-paused', className)}>
{failed ? (
<div className="flex size-full items-center justify-center text-muted-foreground">
<Gamepad2 className="size-1/3 max-h-8"/>
+68 -2
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render} from '@testing-library/react';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, fireEvent, render} from '@testing-library/react';
import GameCover from './GameCover.jsx';
import {reportBrokenImage} from '../utils/brokenImages.js';
@@ -8,6 +8,7 @@ vi.mock('../utils/brokenImages.js', () => ({COVER: 'COVER', reportBrokenImage: v
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
@@ -38,4 +39,69 @@ describe('GameCover', () => {
fireEvent.error(container.querySelector('img'));
expect(reportBrokenImage).toHaveBeenCalledWith(220, 'COVER');
});
describe('completed effect', () => {
const FX = 'fx fx-cover-gold-shine';
let callbacks;
let observers;
beforeEach(() => {
callbacks = [];
observers = [];
vi.stubGlobal('IntersectionObserver', class {
constructor(callback) {
callbacks.push(callback);
observers.push(this);
}
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
});
});
const tell = (target, isIntersecting) => act(() => callbacks[0]([{target, isIntersecting}]));
it('animates on screen, holds its frame off screen and goes on when it comes back', () => {
const {container} = render(<GameCover appid={620} fx={FX}/>);
const root = container.firstChild;
expect(root.classList.contains('fx-cover-gold-shine')).toBe(true);
expect(root.classList.contains('fx-paused')).toBe(false);
tell(root, false);
expect(root.classList.contains('fx-paused')).toBe(true);
tell(root, true);
expect(root.classList.contains('fx-paused')).toBe(false);
});
it('watches every cover with one shared observer', () => {
const {container} = render(<><GameCover appid={620} fx={FX}/><GameCover appid={220} fx={FX}/></>);
expect(observers).toHaveLength(1);
expect(observers[0].observe).toHaveBeenCalledTimes(2);
expect(observers[0].observe).toHaveBeenCalledWith(container.children[0]);
});
it('stops watching when the cover goes', () => {
const {unmount} = render(<GameCover appid={620} fx={FX}/>);
unmount();
expect(observers[0].unobserve).toHaveBeenCalledTimes(1);
expect(observers[0].disconnect).toHaveBeenCalledTimes(1);
});
it('does not watch a cover without an effect', () => {
render(<GameCover appid={620}/>);
expect(observers).toHaveLength(0);
});
it('animates where the browser has no IntersectionObserver', () => {
vi.stubGlobal('IntersectionObserver', undefined);
const {container} = render(<GameCover appid={620} fx={FX}/>);
expect(container.firstChild.classList.contains('fx-paused')).toBe(false);
});
});
});
+14 -3
View File
@@ -6,6 +6,7 @@ import {toast} from 'sonner';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {cn} from '@/lib/utils';
import GameCover from './GameCover.jsx';
import GameStatusBadge from './GameStatusBadge.jsx';
import ProfileArtMenu from './ProfileArtMenu.jsx';
@@ -16,12 +17,15 @@ import {formatPlaytime} from '../utils/games.js';
import {setGameHidden} from '../utils/hiddenGames.js';
import {isSteamKey} from '../utils/manualGames.js';
import {platformLabel} from '../utils/platforms.js';
import {coverEffect, detailEffect} from '../utils/rewards.js';
const SPEEDRUN_PREFIX = 'https://www.speedrun.com/';
// The spans of each completed effect decoration; effects.css places them.
const DETAIL_ELEMENTS = {'fx-detail-gold-shine': 8, 'fx-detail-neon-pulse': 1, 'fx-detail-holo': 1, 'fx-detail-ember': 6};
export default function GameHeroCard({game, manual, auditor, profileStyle, onCorrect, onMerge, onHiddenChanged, onProfileStyleSaved}) {
const {t} = useTranslation();
const {appearance} = usePreferences();
const {appearance, equipped} = usePreferences();
const saveAppearance = useSaveAppearance();
const [hiding, setHiding] = useState(false);
@@ -31,6 +35,7 @@ export default function GameHeroCard({game, manual, auditor, profileStyle, onCor
? game.speedrunUrl : null;
const isBackground = appearance.backgroundAppid === game.appid;
const isBanner = appearance.showBanner && appearance.bannerAppid === game.appid;
const detailFx = detailEffect(equipped, game.status);
const toggleHidden = async () => {
const hidden = !game.hidden;
@@ -54,8 +59,14 @@ export default function GameHeroCard({game, manual, auditor, profileStyle, onCor
return (
<Card className="relative overflow-hidden p-0 gap-0 md:flex-row">
<GameCover appid={game.appid} imageUrl={game.imageUrl} className="w-full rounded-none md:w-96 md:shrink-0"/>
<div className="flex flex-1 flex-col justify-between gap-4 p-6">
{detailFx && (
<div aria-hidden="true" className={cn('fx pointer-events-none absolute inset-0', detailFx)}>
{Array.from({length: DETAIL_ELEMENTS[detailFx]}, (_, i) => <span key={i}/>)}
</div>
)}
<GameCover appid={game.appid} imageUrl={game.imageUrl} fx={coverEffect(equipped, game.status)}
className="w-full rounded-none md:w-96 md:shrink-0"/>
<div className="relative flex flex-1 flex-col justify-between gap-4 p-6">
<div>
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">
{game.name ?? `App ${game.appid}`}
+7
View File
@@ -27,6 +27,13 @@ describe('PageBackground', () => {
expect(container.innerHTML).toBe('');
});
it('shows a worn animated background instead of the art', () => {
const {container} = render(<PageBackground appid={620} effect="aurora"/>);
expect(container.querySelector('[data-effect="aurora"]')).not.toBeNull();
expect(container.querySelector('img')).toBeNull();
});
it('starts with the library art again for another game', () => {
const {container, rerender} = render(<PageBackground appid={620}/>);
fireEvent.error(container.querySelector('img'));
+8 -3
View File
@@ -1,15 +1,20 @@
import {cn} from '@/lib/utils';
import EffectBackground from './EffectBackground.jsx';
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.
*
* @param effect a worn banner background (utils/rewards.js bannerEffect); it replaces the art
*/
export default function HeroBanner({appid}) {
export default function HeroBanner({appid, effect}) {
return (
<>
<HeroArt key={appid} appid={appid} className="pointer-events-none absolute inset-0 size-full object-cover opacity-60"/>
<div className={cn('pointer-events-none absolute inset-0', appid
{effect
? <EffectBackground effect={effect} className="absolute inset-0"/>
: <HeroArt key={appid} appid={appid} className="pointer-events-none absolute inset-0 size-full object-cover opacity-60"/>}
<div className={cn('pointer-events-none absolute inset-0', effect || appid
? 'bg-gradient-to-r from-card via-card/85 to-card/60'
: 'bg-gradient-to-br from-primary/15 via-transparent to-transparent')}/>
</>
+4 -2
View File
@@ -6,8 +6,9 @@ import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import GameCover from './GameCover.jsx';
import {formatPlaytime} from '../utils/games.js';
import {coverEffect} from '../utils/rewards.js';
export default function MostPlayedCard({games, syncing}) {
export default function MostPlayedCard({games, syncing, equipped}) {
const {t} = useTranslation();
const maxMinutes = games[0]?.playtimeMinutes || 1;
return (
@@ -37,7 +38,8 @@ export default function MostPlayedCard({games, syncing}) {
<span className="w-4 text-right text-sm font-semibold text-muted-foreground tabular-nums">
{index + 1}
</span>
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-16 sm:w-24 shrink-0"/>
<GameCover appid={g.appid} imageUrl={g.imageUrl} fx={coverEffect(equipped, g.status)}
className="w-16 sm:w-24 shrink-0"/>
<div className="min-w-0 flex-1">
<div className="truncate font-medium group-hover:text-primary transition-colors">
{g.name ?? `App ${g.appid}`}
+12 -2
View File
@@ -1,9 +1,19 @@
import EffectBackground from './EffectBackground.jsx';
import HeroArt from './HeroArt.jsx';
/**
* Game art behind the whole page, blurred and dimmed so text on it stays readable.
* A worn animated background (utils/rewards.js backgroundEffect) or the game art behind the whole page, dimmed
* (and the art blurred) so text on it stays readable. The effect wins over the art.
*/
export default function PageBackground({appid}) {
export default function PageBackground({appid, effect}) {
if (effect) {
return (
<>
<EffectBackground effect={effect} className="fixed inset-0 -z-10"/>
<div aria-hidden="true" className="pointer-events-none fixed inset-0 -z-10 bg-background/40"/>
</>
);
}
if (!appid) return null;
return (
+16 -1
View File
@@ -11,7 +11,7 @@ import {usePreferences} from '../context/PreferencesContext.jsx';
import usePersistSettings from '../hooks/usePersistSettings.js';
import useSaveResponse from '../hooks/useSaveResponse.js';
import {FAVORITE_PLATFORMS, saveFavoritePlatform} from '../utils/platforms.js';
import {LANGUAGES, saveSettings} from '../utils/settings.js';
import {LANGUAGES, savePlatformMails, saveSettings} from '../utils/settings.js';
// Radix Select has no empty value.
const NO_FAVORITE = 'NONE';
@@ -41,6 +41,13 @@ export default function PreferencesCard() {
}
};
const handlePlatformMailsChange = async (next) => {
if (await runSave(() => savePlatformMails(next))) {
updateProfile((p) => ({...p, settings: {...p.settings, platformMails: next}}));
toast.success(t('SETTINGS.SAVED'));
}
};
return (
<Card>
<CardHeader>
@@ -83,6 +90,14 @@ export default function PreferencesCard() {
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<Label htmlFor="platformMails">{t('SETTINGS.PLATFORM_MAILS')}</Label>
<p className="text-xs text-muted-foreground">{t('SETTINGS.PLATFORM_MAILS_HINT')}</p>
</div>
<Switch id="platformMails" checked={profile?.settings?.platformMails ?? true}
onCheckedChange={handlePlatformMailsChange}/>
</div>
</CardContent>
</Card>
);
+12 -4
View File
@@ -12,7 +12,7 @@ import {
import {hasSession} from '../utils/auth.js';
import useSession from '../hooks/useSession.js';
import {LANGUAGES, saveSettings} from '../utils/settings.js';
import {equippedAccent, loadWallet} from '../utils/rewards.js';
import {cacheEquipped, equippedAccent, loadWallet, readCachedEquipped} from '../utils/rewards.js';
import {storage} from '../utils/storage.js';
const DARK_MODE_STORAGE_KEY = 'tbp_dark_mode';
@@ -52,17 +52,23 @@ export function PreferencesProvider({children}) {
// Pile Points: balance, shop and worn looks of Core; null until loaded.
const [wallet, setWallet] = useState(null);
// The worn looks: the cached ones until the wallet answers.
const [equipped, setEquipped] = useState(readCachedEquipped);
// Layout effect: the cached look is set before the first paint.
useLayoutEffect(() => {
applyAppearance(appearance, equippedAccent(wallet?.equipped));
applyAppearance(appearance, equippedAccent(equipped));
cacheAppearance(appearance);
}, [appearance, wallet]);
cacheEquipped(equipped);
}, [appearance, equipped]);
const reloadWallet = useCallback(async () => {
const started = generation.current;
const {ok, data} = await loadWallet();
if (ok && generation.current === started) setWallet(data);
if (ok && generation.current === started) {
setWallet(data);
setEquipped(data.equipped ?? {});
}
}, []);
// Grows on every language pick, so a load that started before does not undo it.
@@ -136,6 +142,7 @@ export function PreferencesProvider({children}) {
savedAppearance.current = DEFAULT_APPEARANCE;
setAppearanceState(DEFAULT_APPEARANCE);
setWallet(null);
setEquipped({});
}, []);
// Without a session the next user must not get this look.
@@ -166,6 +173,7 @@ export function PreferencesProvider({children}) {
appearance,
changeAppearance,
wallet,
equipped,
reloadWallet,
syncPreferences,
resetAppearance,
+29
View File
@@ -109,3 +109,32 @@ describe('PreferencesProvider theme color', () => {
expect(themeColor()).toBe('#09090b');
});
});
describe('PreferencesProvider worn looks', () => {
it('has the cached worn looks before the wallet answers and caches the answer', async () => {
localStorage.setItem('tbp_session', '1');
localStorage.setItem('tbp_equipped', JSON.stringify({PAGE_BACKGROUND: 'BG_GRID'}));
fetchRoutes({
'GET /api/users/me': json(200, {displayName: 'Lobo', settings: {}}),
'GET /api/rewards/wallet': json(200, {balance: 0, catalog: [], owned: [], equipped: {PAGE_BACKGROUND: 'BG_MESH'}, history: []}),
});
render(<PreferencesProvider><Spy/></PreferencesProvider>);
expect(preferences.wallet).toBeNull();
expect(preferences.equipped).toEqual({PAGE_BACKGROUND: 'BG_GRID'});
await act(() => preferences.syncPreferences());
expect(preferences.equipped).toEqual({PAGE_BACKGROUND: 'BG_MESH'});
expect(JSON.parse(localStorage.getItem('tbp_equipped'))).toEqual({PAGE_BACKGROUND: 'BG_MESH'});
});
it('ignores a damaged cache', () => {
localStorage.setItem('tbp_session', '1');
localStorage.setItem('tbp_equipped', '[1]');
render(<PreferencesProvider><Spy/></PreferencesProvider>);
expect(preferences.equipped).toEqual({});
});
});
+355
View File
@@ -0,0 +1,355 @@
/*
* Pile Points effects (utils/rewards.js LOOKS, components/EffectBackground.jsx). Only transform and opacity move.
* Every effect looks good at frame 0, because that is the static picture: no animation, no motion.
* Colors come from --primary, --foreground and --logo-*, so an effect follows the accent and works in both modes.
* Backgrounds are a size container, so cq units size and move the shapes in a page and in a banner alike.
*/
@keyframes fx-sweep { 0% { transform: translateX(-100%); } 35%, 100% { transform: translateX(100%); } }
@keyframes fx-slide { 0% { transform: translateX(-33.33%); } 60%, 100% { transform: translateX(33.33%); } }
@keyframes fx-breathe { from { opacity: 0.4; } to { opacity: 1; } }
@keyframes fx-glow { from { opacity: 0.5; transform: scale(0.95); } to { opacity: 1; transform: scale(1.05); } }
@keyframes fx-spark-rise { from { transform: translateY(0); opacity: 1; } to { transform: translateY(-45%); opacity: 0; } }
@keyframes fx-ember { from { transform: translateY(0); opacity: 0.9; } to { transform: translateY(-3rem); opacity: 0; } }
@keyframes fx-twinkle { 0%, 100% { transform: scale(0.2); opacity: 0; } 50% { transform: scale(1); opacity: 1; } }
/* ---- COMPLETED_EFFECT, cover of a tile (GameCover has relative and overflow-hidden) ---- */
.fx-cover-gold-shine::after,
.fx-cover-neon-pulse::after,
.fx-cover-holo::after,
.fx-cover-ember::before,
.fx-cover-ember::after {
content: "";
position: absolute;
pointer-events: none;
}
/* The golden border is an inside outline in the look (a ring would be clipped by the card around a flush cover). */
.fx-cover-gold-shine::after {
inset: 0;
background: linear-gradient(115deg, transparent 40%, rgb(255 255 255 / 0.35) 50%, transparent 60%);
transform: translateX(-100%);
animation: fx-sweep 4s ease-in-out infinite;
}
/* The ring is an inside outline in the look; the glow is inside, because the cover clips outside shadows. */
.fx-cover-neon-pulse::after {
inset: 0;
border-radius: inherit;
box-shadow: inset 0 0 14px 2px var(--primary);
opacity: 0.7;
animation: fx-breathe 2.4s ease-in-out infinite alternate;
}
.fx-cover-holo { isolation: isolate; }
.fx-cover-holo::after {
top: 0;
bottom: 0;
left: -100%;
width: 300%;
mix-blend-mode: overlay;
background: linear-gradient(120deg,
transparent 30%,
color-mix(in oklab, var(--logo-teal) 45%, transparent) 40%,
color-mix(in oklab, var(--primary) 45%, transparent) 50%,
color-mix(in oklab, var(--logo-violet) 45%, transparent) 60%,
transparent 70%);
animation: fx-slide 6s ease-in-out infinite;
}
.fx-cover-ember::before {
inset: 0;
background: linear-gradient(to top, rgb(249 115 22 / 0.35), transparent 55%);
}
/* Three sparks as gradient dots in one layer: they rise together. */
.fx-cover-ember::after {
inset: 0;
background:
radial-gradient(circle 2px at 22% 90%, #fdba74 60%, transparent),
radial-gradient(circle 2px at 52% 78%, #fdba74 60%, transparent),
radial-gradient(circle 2px at 80% 92%, #fdba74 60%, transparent);
animation: fx-spark-rise 3s ease-out infinite;
}
/* ---- COMPLETED_EFFECT, details page (decoration behind the text of GameHeroCard; spans are the items) ---- */
.fx-detail-gold-shine > span,
.fx-detail-ember > span {
position: absolute;
left: var(--x);
top: var(--y);
animation-delay: var(--d);
}
.fx-detail-gold-shine > span {
width: 14px;
height: 14px;
background: #fbbf24;
clip-path: polygon(50% 0, 62% 38%, 100% 50%, 62% 62%, 50% 100%, 38% 62%, 0 50%, 38% 38%);
transform: scale(0.6);
animation: fx-twinkle 2.6s ease-in-out infinite;
}
.fx-detail-gold-shine > :nth-child(1) { --x: 46%; --y: 12%; --d: 0s; }
.fx-detail-gold-shine > :nth-child(2) { --x: 62%; --y: 70%; --d: -0.4s; }
.fx-detail-gold-shine > :nth-child(3) { --x: 78%; --y: 18%; --d: -0.9s; }
.fx-detail-gold-shine > :nth-child(4) { --x: 92%; --y: 55%; --d: -1.3s; }
.fx-detail-gold-shine > :nth-child(5) { --x: 54%; --y: 42%; --d: -1.7s; }
.fx-detail-gold-shine > :nth-child(6) { --x: 85%; --y: 86%; --d: -2.1s; }
.fx-detail-gold-shine > :nth-child(7) { --x: 70%; --y: 44%; --d: -2.4s; }
.fx-detail-gold-shine > :nth-child(8) { --x: 40%; --y: 82%; --d: -0.2s; }
.fx-detail-neon-pulse > span {
position: absolute;
inset: 0;
background: radial-gradient(ellipse at 70% 40%, color-mix(in oklab, var(--primary) 25%, transparent), transparent 70%);
opacity: 0.8;
animation: fx-glow 2.4s ease-in-out infinite alternate;
}
/* A faint rainbow sheen over the top edge. */
.fx-detail-holo > span {
position: absolute;
inset: 0 0 auto;
height: 35%;
background: linear-gradient(90deg,
transparent,
color-mix(in oklab, var(--logo-teal) 18%, transparent),
color-mix(in oklab, var(--primary) 18%, transparent),
color-mix(in oklab, var(--logo-violet) 18%, transparent),
transparent);
animation: fx-breathe 6s ease-in-out infinite alternate;
}
.fx-detail-ember > span {
width: 4px;
height: 4px;
border-radius: 50%;
background: #fb923c;
box-shadow: 0 0 6px #f97316;
animation: fx-ember 3s ease-out infinite;
}
.fx-detail-ember > :nth-child(1) { --x: 46%; --y: 70%; --d: 0s; }
.fx-detail-ember > :nth-child(2) { --x: 58%; --y: 82%; --d: -0.5s; }
.fx-detail-ember > :nth-child(3) { --x: 68%; --y: 66%; --d: -1s; }
.fx-detail-ember > :nth-child(4) { --x: 78%; --y: 84%; --d: -1.5s; }
.fx-detail-ember > :nth-child(5) { --x: 88%; --y: 72%; --d: -2s; }
.fx-detail-ember > :nth-child(6) { --x: 52%; --y: 90%; --d: -2.5s; }
/* ---- PAGE_BACKGROUND and BANNER_BACKGROUND (EffectBackground) ---- */
.fx-bg { container-type: size; }
.fx-bg > * { position: absolute; }
/* triangles: three groups of triangles drifting apart */
@keyframes fx-tri-a { to { transform: translate(60px, -40px) rotate(5deg); } }
@keyframes fx-tri-b { to { transform: translate(-70px, 30px) rotate(-6deg); } }
@keyframes fx-tri-c { to { transform: translate(40px, 50px) rotate(4deg); } }
.fx-bg-triangles { color: var(--primary); }
.fx-bg-triangles g { transform-box: fill-box; transform-origin: center; }
.fx-bg-triangles g:nth-child(1) { animation: fx-tri-a 45s ease-in-out infinite alternate; }
.fx-bg-triangles g:nth-child(2) { animation: fx-tri-b 58s ease-in-out infinite alternate; }
.fx-bg-triangles g:nth-child(3) { animation: fx-tri-c 70s ease-in-out infinite alternate; }
/* starfield: three tiled layers (a tile of --t px moves up by one tile, so the loop is seamless) and 6 twinkles */
@keyframes fx-drift { to { transform: translateY(calc(var(--t) * -1)); } }
.fx-bg-starfield { --star: color-mix(in oklab, var(--foreground) 70%, transparent); }
.fx-bg-starfield > :nth-child(-n+3) {
inset: 0;
bottom: calc(var(--t) * -1);
background-size: var(--t) var(--t);
will-change: transform;
animation: fx-drift linear infinite;
}
.fx-bg-starfield > :nth-child(1) {
--t: 90px;
animation-duration: 60s;
background-image:
radial-gradient(circle at 20% 30%, var(--star) 0 1px, transparent 1.5px),
radial-gradient(circle at 70% 80%, var(--star) 0 1px, transparent 1.5px);
}
.fx-bg-starfield > :nth-child(2) {
--t: 140px;
animation-duration: 90s;
background-image:
radial-gradient(circle at 15% 65%, var(--star) 0 1px, transparent 1.5px),
radial-gradient(circle at 55% 20%, var(--star) 0 1px, transparent 1.5px),
radial-gradient(circle at 85% 75%, var(--star) 0 1px, transparent 1.5px);
}
.fx-bg-starfield > :nth-child(3) {
--t: 220px;
animation-duration: 120s;
background-image:
radial-gradient(circle at 30% 40%, var(--star) 0 1.5px, transparent 2px),
radial-gradient(circle at 75% 85%, var(--star) 0 1.5px, transparent 2px);
}
.fx-bg-starfield > :nth-child(n+4) {
left: var(--x);
top: var(--y);
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--star);
box-shadow: 0 0 6px var(--star);
opacity: 0.65;
animation: fx-breathe 3s ease-in-out infinite alternate;
animation-delay: var(--d);
}
.fx-bg-starfield > :nth-child(4) { --x: 12%; --y: 20%; --d: 0s; }
.fx-bg-starfield > :nth-child(5) { --x: 33%; --y: 62%; --d: -0.6s; }
.fx-bg-starfield > :nth-child(6) { --x: 51%; --y: 14%; --d: -1.2s; }
.fx-bg-starfield > :nth-child(7) { --x: 68%; --y: 48%; --d: -1.8s; }
.fx-bg-starfield > :nth-child(8) { --x: 84%; --y: 26%; --d: -2.4s; }
.fx-bg-starfield > :nth-child(9) { --x: 92%; --y: 78%; --d: -0.3s; }
/* pixels: squares rise and turn; fade in at the base spot, out at the top, so the loop shows no jump */
@keyframes fx-float {
0% { transform: translateY(0) rotate(0); opacity: 0; }
10%, 90% { opacity: 0.8; }
100% { transform: translateY(calc(var(--y) * -1cqh - 10px)) rotate(180deg); opacity: 0; }
}
.fx-bg-pixels > * {
left: calc(var(--x) * 1%);
top: calc(var(--y) * 1%);
width: 4px;
height: 4px;
opacity: 0.8;
background: var(--primary);
animation: fx-float var(--dur) linear infinite;
animation-delay: var(--delay);
}
.fx-bg-pixels > :nth-child(4n+2) { background: var(--logo-violet); }
.fx-bg-pixels > :nth-child(4n+3) { background: var(--logo-blue); }
.fx-bg-pixels > :nth-child(4n) { background: var(--logo-teal); }
/* aurora: two soft ribbons waving across the top (elliptic gradients have no hard edge) */
@keyframes fx-wave-a {
from { transform: rotate(-8deg) translateX(-10%) scaleY(1); }
to { transform: rotate(-8deg) translateX(10%) scaleY(1.3); }
}
.fx-bg-aurora > * {
left: -30%;
width: 160%;
height: 40%;
transform: rotate(-8deg);
will-change: transform;
animation: fx-wave-a 20s ease-in-out infinite alternate;
}
.fx-bg-aurora > :nth-child(1) {
top: -5%;
background: radial-gradient(closest-side, color-mix(in oklab, var(--primary) 28%, transparent), transparent);
}
.fx-bg-aurora > :nth-child(2) {
top: 12%;
background: radial-gradient(closest-side, color-mix(in oklab, var(--logo-teal) 24%, transparent), transparent);
animation-duration: 26s;
animation-direction: alternate-reverse;
}
/* mesh: four big blobs in the corners; the gradient edge is the blur, a filter would cost every frame */
@keyframes fx-blob {
from { transform: translate(-8%, -6%) scale(0.9); }
to { transform: translate(8%, 6%) scale(1.1); }
}
.fx-bg-mesh > * {
width: 60cqmax;
height: 60cqmax;
will-change: transform;
animation: fx-blob 25s ease-in-out infinite alternate;
}
.fx-bg-mesh > :nth-child(1) {
left: -15%; top: -20%;
background: radial-gradient(closest-side, color-mix(in oklab, var(--primary) 30%, transparent), transparent);
}
.fx-bg-mesh > :nth-child(2) {
right: -15%; top: -10%;
background: radial-gradient(closest-side, color-mix(in oklab, var(--logo-teal) 26%, transparent), transparent);
animation-duration: 28s;
}
.fx-bg-mesh > :nth-child(3) {
left: -10%; bottom: -25%;
background: radial-gradient(closest-side, color-mix(in oklab, var(--logo-violet) 26%, transparent), transparent);
animation-duration: 32s;
}
.fx-bg-mesh > :nth-child(4) {
right: -20%; bottom: -20%;
background: radial-gradient(closest-side, color-mix(in oklab, var(--primary) 22%, transparent), transparent);
animation-duration: 35s;
}
/* grid: a perspective floor moving one cell (40px) toward the viewer; the height keeps all points in front of the camera */
@keyframes fx-floor {
from { transform: perspective(600px) rotateX(62deg) translateY(0); }
to { transform: perspective(600px) rotateX(62deg) translateY(40px); }
}
.fx-bg-grid > :nth-child(1) {
inset: 0 0 auto;
height: 45%;
background: linear-gradient(to bottom, transparent, color-mix(in oklab, var(--primary) 25%, transparent));
}
.fx-bg-grid > :nth-child(2) {
left: -100%;
top: 45%;
width: 300%;
height: 500px;
transform-origin: top center;
transform: perspective(600px) rotateX(62deg);
background-image:
repeating-linear-gradient(0deg, color-mix(in oklab, var(--primary) 35%, transparent) 0 1px, transparent 1px 40px),
repeating-linear-gradient(90deg, color-mix(in oklab, var(--primary) 35%, transparent) 0 1px, transparent 1px 40px);
will-change: transform;
animation: fx-floor 2s linear infinite;
}
/* waves: three bands 2 container widths wide (one period = one width), moving one width sideways */
@keyframes fx-wave-x { to { transform: translateX(-50%); } }
.fx-bg-waves > svg {
left: 0;
bottom: 0;
width: 200%;
color: var(--primary);
animation: fx-wave-x linear infinite;
}
.fx-bg-waves > :nth-child(1) { height: 25%; opacity: 0.08; animation-duration: 14s; }
.fx-bg-waves > :nth-child(2) { height: 32%; opacity: 0.12; animation-duration: 20s; animation-direction: reverse; }
.fx-bg-waves > :nth-child(3) { height: 40%; opacity: 0.18; animation-duration: 28s; }
/* bokeh: soft circles drifting up */
@keyframes fx-bokeh {
0% { transform: translateY(8cqh); opacity: 0; }
50% { opacity: 1; }
100% { transform: translateY(-20cqh); opacity: 0; }
}
.fx-bg-bokeh > * {
left: calc(var(--x) * 1%);
top: calc(var(--y) * 1%);
width: calc(var(--s) * 1cqmin);
height: calc(var(--s) * 1cqmin);
opacity: 0.6;
background: radial-gradient(closest-side, color-mix(in oklab, var(--primary) 25%, transparent), transparent);
animation: fx-bokeh var(--dur) ease-in-out infinite;
animation-delay: var(--delay);
}
.fx-bg-bokeh > :nth-child(even) {
background: radial-gradient(closest-side, color-mix(in oklab, var(--logo-teal) 22%, transparent), transparent);
}
/* ---- Off screen covers hold their frame (components/GameCover.jsx) ---- */
.fx-paused,
.fx-paused *,
.fx-paused::before,
.fx-paused::after {
animation-play-state: paused;
}
/* ---- Static mode: the system setting and the "Reduce animations" setting (utils/appearance.js) ---- */
@media (prefers-reduced-motion: reduce) {
.fx, .fx *, .fx::before, .fx::after { animation: none !important; }
}
html[data-reduce-motion] .fx,
html[data-reduce-motion] .fx *,
html[data-reduce-motion] .fx::before,
html[data-reduce-motion] .fx::after {
animation: none !important;
}
+38
View File
@@ -0,0 +1,38 @@
import {useEffect, useState} from 'react';
// One observer for every element, so 80 covers cost one observer and not 80. It lives while something is watched.
const setters = new Map();
let observer = null;
const sharedObserver = () => {
observer ??= new IntersectionObserver((entries) => {
for (const entry of entries) setters.get(entry.target)?.(entry.isIntersecting);
}, {rootMargin: '100px'});
return observer;
};
/**
* Whether the element of `ref` is on screen (or 100 px near it). True while `enabled` is false, and where the
* browser has no IntersectionObserver; it also starts true, so the first paint is not held back.
*/
export default function useInView(ref, enabled) {
const [inView, setInView] = useState(true);
useEffect(() => {
const element = ref.current;
if (!enabled || !element || typeof IntersectionObserver === 'undefined') return undefined;
const watcher = sharedObserver();
setters.set(element, setInView);
watcher.observe(element);
return () => {
setters.delete(element);
watcher.unobserve(element);
if (setters.size === 0) {
watcher.disconnect();
observer = null;
}
};
}, [ref, enabled]);
return !enabled || inView;
}
+3 -3
View File
@@ -54,7 +54,7 @@ describe('useSaveAppearance', () => {
const [url, request] = fetch.mock.calls[0];
expect(url).toContain('/api/users/settings/appearance');
expect(request.method).toBe('PUT');
expect(JSON.parse(request.body)).toEqual({accent: 'blue', corners: 'default', wideLayout: false, backgroundAppid: null, bannerAppid: null, showBanner: true});
expect(JSON.parse(request.body)).toEqual({accent: 'blue', corners: 'default', wideLayout: false, backgroundAppid: null, bannerAppid: null, showBanner: true, reduceMotion: false});
});
it('puts the old value back when Auth refuses', async () => {
@@ -119,7 +119,7 @@ describe('useSaveAppearance', () => {
});
it('waits for a running load from Auth, then keeps its values for the fields not changed here', async () => {
const fromServer = {accent: 'green', corners: 'default', wideLayout: false, backgroundAppid: null, bannerAppid: 620, showBanner: false};
const fromServer = {accent: 'green', corners: 'default', wideLayout: false, backgroundAppid: null, bannerAppid: 620, showBanner: false, reduceMotion: false};
let answerMe;
fetchRoutes({
[PUT]: json(204),
@@ -206,7 +206,7 @@ describe('useSaveAppearance', () => {
});
expect(result.current.preferences.appearance.corners).toBe('round');
expect(puts()[2]).toEqual({accent: 'green', corners: 'round', wideLayout: true, backgroundAppid: null, bannerAppid: null, showBanner: true});
expect(puts()[2]).toEqual({accent: 'green', corners: 'round', wideLayout: true, backgroundAppid: null, bannerAppid: null, showBanner: true, reduceMotion: false});
});
it('sends nothing for a change that waited for the load when a reset came first', async () => {
+322
View File
@@ -0,0 +1,322 @@
import {useCallback, useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {Loader2, Search, Send, X} from 'lucide-react';
import {toast} from 'sonner';
import {Button} from '@/components/ui/button';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {Textarea} from '@/components/ui/textarea';
import PageContainer from '../components/PageContainer.jsx';
import {fetchUsers} from '../utils/admin.js';
import {fetchBroadcasts, fetchRecipientCount, sendBroadcast, sendTestBroadcast} from '../utils/broadcasts.js';
import {formatDateTime} from '../utils/format.js';
import {MAX_BROADCAST_BODY_LENGTH, MAX_BROADCAST_SUBJECT_LENGTH} from '../utils/limits.js';
const emptyForm = () => ({subjectEn: '', subjectDe: '', bodyEn: '', bodyDe: ''});
const SEARCH_SIZE = 20;
/**
* PLATFORM_ADMIN only: write a mail in both languages, send it to all active users or to picked ones, and see the
* history with the counts. Auth checks the role.
*/
export default function AdminBroadcastPage() {
const {t, i18n} = useTranslation();
const [entries, setEntries] = useState([]);
const [status, setStatus] = useState('loading');
const [form, setForm] = useState(emptyForm);
const [mode, setMode] = useState('all');
const [picked, setPicked] = useState([]);
const [query, setQuery] = useState('');
const [found, setFound] = useState([]);
const [confirm, setConfirm] = useState(null);
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
try {
const response = await fetchBroadcasts();
if (response.status === 401) return;
if (response.status === 403) {
setStatus('forbidden');
return;
}
if (!response.ok) {
setStatus('error');
return;
}
setEntries((await response.json()).results);
setStatus('ready');
} catch (err) {
console.error('Admin broadcast error:', err);
setStatus('error');
}
}, []);
useEffect(() => {
load();
}, [load]);
const trimmed = () => ({
subjectEn: form.subjectEn.trim(),
subjectDe: form.subjectDe.trim(),
bodyEn: form.bodyEn.trim(),
bodyDe: form.bodyDe.trim(),
});
const incomplete = Object.values(trimmed()).some((value) => !value);
const userIds = () => (mode === 'all' ? null : picked.map((user) => user.id));
const change = (field) => (event) => setForm({...form, [field]: event.target.value});
const sendTest = async () => {
setBusy(true);
try {
const response = await sendTestBroadcast(trimmed());
if (response.status === 401) return;
if (response.ok) toast.success(t('ADMIN_BROADCAST.TEST_SENT'));
else toast.error(t('ADMIN_BROADCAST.ERROR'));
} catch (err) {
console.error('Admin broadcast test error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(false);
}
};
const submit = async (event) => {
event.preventDefault();
setConfirm({count: null});
try {
const response = await fetchRecipientCount(userIds());
if (response.status === 401) return;
if (!response.ok) throw new Error(`count ${response.status}`);
const {count} = await response.json();
setConfirm((current) => (current ? {count} : current));
} catch (err) {
console.error('Admin broadcast count error:', err);
toast.error(t('ADMIN_BROADCAST.ERROR'));
setConfirm(null);
}
};
const send = async () => {
setBusy(true);
try {
const response = await sendBroadcast({...trimmed(), userIds: userIds()});
if (response.status === 401) return;
if (response.ok) {
const data = await response.json();
toast.success(t('ADMIN_BROADCAST.SENT', {count: data.recipientCount}));
setConfirm(null);
setForm(emptyForm());
setPicked([]);
setFound([]);
await load();
} else {
toast.error(t(response.status === 409 ? 'ADMIN_BROADCAST.NO_RECIPIENTS' : 'ADMIN_BROADCAST.ERROR'));
}
} catch (err) {
console.error('Admin broadcast send error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(false);
}
};
const search = async () => {
try {
const response = await fetchUsers(query.trim(), 'ACTIVE', 0, SEARCH_SIZE);
if (response.status === 401) return;
if (!response.ok) throw new Error(`search ${response.status}`);
const {content} = await response.json();
setFound(content.filter((user) => !user.blocked && !user.deletedAt));
} catch (err) {
console.error('Admin broadcast search error:', err);
toast.error(t('ADMIN_BROADCAST.ERROR'));
}
};
const toggle = (user) => setPicked((current) => (current.some((p) => p.id === user.id)
? current.filter((p) => p.id !== user.id)
: [...current, {id: user.id, email: user.email, displayName: user.displayName}]));
if (status === 'forbidden') {
return (
<PageContainer className="px-4 py-6 md:px-8">
<p className="text-sm text-muted-foreground">{t('ADMIN_BROADCAST.NO_ACCESS')}</p>
</PageContainer>
);
}
return (
<PageContainer className="space-y-4 px-4 py-6 md:px-8">
<Card>
<CardHeader>
<CardTitle>{t('ADMIN_BROADCAST.NEW')}</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={submit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="broadcastSubjectEn">{t('ADMIN_BROADCAST.SUBJECT_EN')}</Label>
<Input id="broadcastSubjectEn" required maxLength={MAX_BROADCAST_SUBJECT_LENGTH}
value={form.subjectEn} onChange={change('subjectEn')}/>
</div>
<div className="space-y-2">
<Label htmlFor="broadcastBodyEn">{t('ADMIN_BROADCAST.BODY_EN')}</Label>
<Textarea id="broadcastBodyEn" required maxLength={MAX_BROADCAST_BODY_LENGTH}
value={form.bodyEn} onChange={change('bodyEn')}/>
</div>
<div className="space-y-2">
<Label htmlFor="broadcastSubjectDe">{t('ADMIN_BROADCAST.SUBJECT_DE')}</Label>
<Input id="broadcastSubjectDe" required maxLength={MAX_BROADCAST_SUBJECT_LENGTH}
value={form.subjectDe} onChange={change('subjectDe')}/>
</div>
<div className="space-y-2">
<Label htmlFor="broadcastBodyDe">{t('ADMIN_BROADCAST.BODY_DE')}</Label>
<Textarea id="broadcastBodyDe" required maxLength={MAX_BROADCAST_BODY_LENGTH}
value={form.bodyDe} onChange={change('bodyDe')}/>
<p className="text-xs text-muted-foreground">{t('ADMIN_BROADCAST.TEXT_HELP')}</p>
</div>
<div className="space-y-2">
<p className="text-sm font-medium">{t('ADMIN_BROADCAST.RECIPIENTS')}</p>
<div className="flex gap-2">
{['all', 'picked'].map((value) => (
<Button key={value} type="button" variant={mode === value ? 'default' : 'outline'}
aria-pressed={mode === value} onClick={() => setMode(value)}>
{t(`ADMIN_BROADCAST.${value.toUpperCase()}`)}
</Button>
))}
</div>
</div>
{mode === 'picked' && (
<div className="space-y-3">
<div className="flex gap-2">
<Input aria-label={t('ADMIN_BROADCAST.SEARCH')} placeholder={t('ADMIN_BROADCAST.SEARCH')}
value={query} onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
search();
}
}}/>
<Button type="button" variant="outline" size="icon"
aria-label={t('ADMIN_BROADCAST.SEARCH')} onClick={search}>
<Search/>
</Button>
</div>
{found.length > 0 && (
<ul className="space-y-1">
{found.map((user) => (
<li key={user.id}>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" className="accent-primary"
checked={picked.some((p) => p.id === user.id)}
onChange={() => toggle(user)}/>
{[user.email, user.displayName].filter(Boolean).join(' · ')}
</label>
</li>
))}
</ul>
)}
<p className="text-sm text-muted-foreground">
{t('ADMIN_BROADCAST.PICKED_COUNT', {count: picked.length})}
</p>
{picked.length > 0 && (
<ul className="flex flex-wrap gap-2">
{picked.map((user) => (
<li key={user.id} className="flex items-center gap-1 rounded-md border px-2 py-1 text-sm">
{user.email}
<button type="button" aria-label={`${t('ADMIN_BROADCAST.REMOVE')} ${user.email}`}
onClick={() => toggle(user)}>
<X className="size-3"/>
</button>
</li>
))}
</ul>
)}
</div>
)}
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" disabled={busy || incomplete} onClick={sendTest}>
{t('ADMIN_BROADCAST.TEST')}
</Button>
<Button type="submit" disabled={busy || incomplete || (mode === 'picked' && picked.length === 0)}>
<Send/>{t('ADMIN_BROADCAST.SEND')}
</Button>
</div>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('ADMIN_BROADCAST.LIST')}</CardTitle>
</CardHeader>
<CardContent>
{status === 'loading' && <Loader2 className="size-5 animate-spin text-muted-foreground"/>}
{status === 'error' && <p className="text-sm text-destructive">{t('ADMIN_BROADCAST.LOAD_ERROR')}</p>}
{status === 'ready' && entries.length === 0 && (
<p className="text-sm text-muted-foreground">{t('ADMIN_BROADCAST.EMPTY')}</p>
)}
{status === 'ready' && entries.length > 0 && (
<ul className="divide-y">
{entries.map((entry) => (
<li key={entry.id} className="space-y-1 py-3">
<p className="text-xs text-muted-foreground">
{formatDateTime(entry.createdAt, i18n.language)}
{' · '}
{t('ADMIN_BROADCAST.COUNTS', {
recipients: entry.recipientCount, sent: entry.sentCount, failed: entry.failedCount,
})}
</p>
<p className="font-medium">{entry.subjectEn}</p>
<p className="font-medium">{entry.subjectDe}</p>
<p className="whitespace-pre-line break-words text-sm text-muted-foreground">{entry.bodyEn}</p>
<p className="whitespace-pre-line break-words text-sm text-muted-foreground">{entry.bodyDe}</p>
</li>
))}
</ul>
)}
</CardContent>
</Card>
<Dialog open={!!confirm} onOpenChange={(open) => { if (!open && !busy) setConfirm(null); }}>
{confirm && (
<DialogContent>
<DialogHeader>
<DialogTitle>{t('ADMIN_BROADCAST.CONFIRM_TITLE')}</DialogTitle>
<DialogDescription asChild>
<div>
{confirm.count === null && <Loader2 className="size-5 animate-spin"/>}
{confirm.count === 0 && t('ADMIN_BROADCAST.NO_RECIPIENTS')}
{confirm.count > 0 && t('ADMIN_BROADCAST.CONFIRM_TEXT', {count: confirm.count})}
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">{t('ADMIN_BROADCAST.CANCEL')}</Button>
</DialogClose>
<Button type="button" disabled={busy || !confirm.count} onClick={send}>
{busy && <Loader2 className="animate-spin"/>}
{t('ADMIN_BROADCAST.CONFIRM')}
</Button>
</DialogFooter>
</DialogContent>
)}
</Dialog>
</PageContainer>
);
}
+161
View File
@@ -0,0 +1,161 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import {toast} from 'sonner';
import AdminBroadcastPage from './AdminBroadcastPage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const ENTRY = {id: 1, subjectEn: 'Maintenance', subjectDe: 'Wartung', bodyEn: 'We pause.', bodyDe: 'Wir pausieren.',
createdBy: 1, createdAt: '2026-10-01T10:00:00Z', recipientCount: 5, sentCount: 4, failedCount: 1};
const USERS = {content: [
{id: 7, email: 'ann@example.com', displayName: 'Ann', blocked: false, deletedAt: null},
{id: 8, email: 'bob@example.com', displayName: 'Bob', blocked: true, deletedAt: null},
]};
const withRoutes = (routes) => fetchRoutes({
'GET /api/admin/broadcasts': json(200, {results: [ENTRY]}),
'GET /api/admin/broadcasts/recipient-count': json(200, {count: 42}),
'POST /api/admin/broadcasts': json(201, {recipientCount: 42}),
'POST /api/admin/broadcasts/test': json(204),
'GET /api/admin/users': json(200, USERS),
...routes,
});
const posts = (suffix) => fetch.mock.calls.filter(([url, init]) => init?.method === 'POST' && url.endsWith(suffix));
const body = (call) => JSON.parse(call[1].body);
const renderPage = () => render(<MemoryRouter><PreferencesProvider><AdminBroadcastPage/></PreferencesProvider></MemoryRouter>);
const fill = () => {
fireEvent.change(screen.getByLabelText('ADMIN_BROADCAST.SUBJECT_EN'), {target: {value: ' Hello '}});
fireEvent.change(screen.getByLabelText('ADMIN_BROADCAST.BODY_EN'), {target: {value: ' Body en '}});
fireEvent.change(screen.getByLabelText('ADMIN_BROADCAST.SUBJECT_DE'), {target: {value: ' Hallo '}});
fireEvent.change(screen.getByLabelText('ADMIN_BROADCAST.BODY_DE'), {target: {value: ' Inhalt '}});
};
const TRIMMED = {subjectEn: 'Hello', subjectDe: 'Hallo', bodyEn: 'Body en', bodyDe: 'Inhalt'};
describe('AdminBroadcastPage', () => {
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
withRoutes({});
});
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
it('lists the history with the counts', async () => {
renderPage();
expect(await screen.findByText('Maintenance')).toBeTruthy();
expect(screen.getByText('Wartung')).toBeTruthy();
expect(screen.getByText(/ADMIN_BROADCAST\.COUNTS/)).toBeTruthy();
});
it('sends the trimmed test mail and keeps the form', async () => {
renderPage();
await screen.findByText('Maintenance');
const test = screen.getByText('ADMIN_BROADCAST.TEST').closest('button');
expect(test.disabled).toBe(true);
fill();
fireEvent.click(test);
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('ADMIN_BROADCAST.TEST_SENT'));
expect(body(posts('/test')[0])).toEqual(TRIMMED);
expect(screen.getByLabelText('ADMIN_BROADCAST.SUBJECT_EN').value).toBe(' Hello ');
});
it('sends to all after the confirmation with the count, then empties the form', async () => {
renderPage();
await screen.findByText('Maintenance');
fill();
fireEvent.click(screen.getByText('ADMIN_BROADCAST.SEND'));
expect(await screen.findByText('ADMIN_BROADCAST.CONFIRM_TEXT:42')).toBeTruthy();
expect(posts('/broadcasts')).toEqual([]);
fireEvent.click(screen.getByText('ADMIN_BROADCAST.CONFIRM'));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('ADMIN_BROADCAST.SENT:42'));
expect(body(posts('/broadcasts')[0])).toEqual({...TRIMMED, userIds: null});
await waitFor(() => expect(screen.getByLabelText('ADMIN_BROADCAST.SUBJECT_EN').value).toBe(''));
expect(screen.queryByText('ADMIN_BROADCAST.CONFIRM_TEXT:42')).toBeNull();
});
it('sends to the picked users only and leaves a blocked one out of the list', async () => {
renderPage();
await screen.findByText('Maintenance');
fill();
const send = screen.getByText('ADMIN_BROADCAST.SEND').closest('button');
fireEvent.click(screen.getByText('ADMIN_BROADCAST.PICKED'));
expect(send.disabled).toBe(true);
fireEvent.change(screen.getByRole('textbox', {name: 'ADMIN_BROADCAST.SEARCH'}), {target: {value: 'a'}});
fireEvent.click(screen.getByRole('button', {name: 'ADMIN_BROADCAST.SEARCH'}));
const ann = await screen.findByLabelText(/ann@example\.com/);
expect(screen.queryByLabelText(/bob@example\.com/)).toBeNull();
fireEvent.click(ann);
expect(screen.getByText('ADMIN_BROADCAST.PICKED_COUNT:1')).toBeTruthy();
fireEvent.click(send);
await screen.findByText('ADMIN_BROADCAST.CONFIRM_TEXT:42');
expect(fetch.mock.calls.some(([url]) => url.includes('/recipient-count?userIds=7'))).toBe(true);
fireEvent.click(screen.getByText('ADMIN_BROADCAST.CONFIRM'));
await waitFor(() => expect(posts('/broadcasts')).toHaveLength(1));
expect(body(posts('/broadcasts')[0]).userIds).toEqual([7]);
});
it('cannot send when nobody would get the mail', async () => {
withRoutes({'GET /api/admin/broadcasts/recipient-count': json(200, {count: 0})});
renderPage();
await screen.findByText('Maintenance');
fill();
fireEvent.click(screen.getByText('ADMIN_BROADCAST.SEND'));
expect(await screen.findByText('ADMIN_BROADCAST.NO_RECIPIENTS')).toBeTruthy();
expect(screen.getByText('ADMIN_BROADCAST.CONFIRM').closest('button').disabled).toBe(true);
});
it('keeps the confirmation closed when it was cancelled before the count arrived', async () => {
let answer;
withRoutes({'GET /api/admin/broadcasts/recipient-count': () => new Promise((resolve) => { answer = resolve; })});
renderPage();
await screen.findByText('Maintenance');
fill();
fireEvent.click(screen.getByText('ADMIN_BROADCAST.SEND'));
fireEvent.click(await screen.findByText('ADMIN_BROADCAST.CANCEL'));
await waitFor(() => expect(screen.queryByText('ADMIN_BROADCAST.CANCEL')).toBeNull());
answer(json(200, {count: 42}));
await waitFor(() => expect(fetch.mock.calls.some(([url]) => url.includes('/recipient-count'))).toBe(true));
await new Promise((resolve) => setTimeout(resolve, 20));
expect(screen.queryByText('ADMIN_BROADCAST.CONFIRM_TEXT:42')).toBeNull();
});
it('does not offer the send while a field is blank', async () => {
renderPage();
await screen.findByText('Maintenance');
fill();
fireEvent.change(screen.getByLabelText('ADMIN_BROADCAST.BODY_DE'), {target: {value: ' '}});
expect(screen.getByText('ADMIN_BROADCAST.SEND').closest('button').disabled).toBe(true);
});
it('shows "no access" to a non-admin', async () => {
withRoutes({'GET /api/admin/broadcasts': json(403, {})});
renderPage();
expect(await screen.findByText('ADMIN_BROADCAST.NO_ACCESS')).toBeTruthy();
expect(screen.queryByText('ADMIN_BROADCAST.SEND')).toBeNull();
});
});
+4 -3
View File
@@ -60,7 +60,7 @@ const Dashboard = () => {
const hasSteamId = !!userData?.steamId;
const showLibrary = hasSteamId || steamData.count > 0;
const {appearance, wallet} = usePreferences();
const {appearance, equipped} = usePreferences();
const steamProfile = useSteamProfile(hasSteamId);
const bannerAppid = appearance.showBanner
? appearance.bannerAppid ?? activity?.recentGames?.[0]?.appid ?? null
@@ -99,7 +99,8 @@ const Dashboard = () => {
switch (id) {
case 'GAME_COUNT': return showLibrary && <SteamGameCount targetCount={steamData.count}/>;
case 'MOST_PLAYED':
return showLibrary && <MostPlayedCard games={topGames.slice(0, TOP_GAMES[size])} syncing={sync.isRunning}/>;
return showLibrary && <MostPlayedCard games={topGames.slice(0, TOP_GAMES[size])} syncing={sync.isRunning}
equipped={equipped}/>;
case 'PLAYING':
return showLibrary && (
<CurrentlyPlayingCard games={playing.slice(0, PLAYING_GAMES[size])} syncing={sync.isRunning} wide={size === 'L'}/>
@@ -127,7 +128,7 @@ const Dashboard = () => {
return (
<PageContainer className="p-4 md:p-8 space-y-6">
<DashboardWelcomeCard bannerAppid={bannerAppid} avatarUrl={steamProfile?.avatarUrl}
equipped={wallet?.equipped}
equipped={equipped}
sync={sync} forceSync={forceSync} editing={editing} onCustomize={() => setEditing(true)}/>
<TileGrid tiles={DASHBOARD_TILES} titles={DASHBOARD_TITLES} layout={layout} renderTile={renderTile} editing={editing}
+52
View File
@@ -0,0 +1,52 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, screen, waitFor} from '@testing-library/react';
import GameDetail from './GameDetail.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
import WalletLoader from '../test/WalletLoader.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const game = {
appid: 220, name: 'Half-Life 2', playtimeMinutes: 100, playtime2Weeks: 0, source: 'STEAM',
rating: null, notes: null, priority: null, hasAchievements: false, statsVisible: false, achievements: [],
};
const renderDetail = (status) => {
fetchRoutes({
'GET /api/users/me': json(200, {settings: {}}),
'GET /api/rewards/wallet': json(200, {balance: 0, catalog: [], owned: [], equipped: {COMPLETED_EFFECT: 'FX_GOLD_SHINE'}, history: []}),
'GET /api/library/games/220': json(200, {...game, status}),
});
return renderWithProviders(<><WalletLoader/><GameDetail/></>, {path: '/library/:appid', entries: ['/library/220']});
};
beforeEach(() => localStorage.setItem('tbp_session', '1'));
afterEach(() => {
cleanup();
localStorage.clear();
});
describe('GameDetail completed effect', () => {
it('decorates the card of a COMPLETED game with the worn effect and its cover', async () => {
const {container} = renderDetail('COMPLETED');
await waitFor(() => expect(container.querySelector('.fx-detail-gold-shine')).not.toBeNull());
const decoration = container.querySelector('.fx-detail-gold-shine');
expect(decoration.getAttribute('aria-hidden')).toBe('true');
expect(decoration.children).toHaveLength(8);
expect(container.querySelector('.fx-cover-gold-shine')).not.toBeNull();
});
it('shows nothing for a FINISHED game', async () => {
const {container} = renderDetail('FINISHED');
await screen.findByText('Half-Life 2');
await waitFor(() => expect(fetch.mock.calls.some(([url]) => url.includes('/rewards/wallet'))).toBe(true));
await act(async () => {});
expect(container.querySelector('.fx-detail-gold-shine')).toBeNull();
expect(container.querySelector('.fx-cover-gold-shine')).toBeNull();
});
});
+42
View File
@@ -0,0 +1,42 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, screen, waitFor} from '@testing-library/react';
import GameLibrary from './GameLibrary.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
import WalletLoader from '../test/WalletLoader.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
vi.mock('../hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})}));
vi.mock('../hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), busy: false})}));
const games = [
{appid: 620, name: 'Portal 2', playtimeMinutes: 0, status: 'COMPLETED'},
{appid: 220, name: 'Half-Life 2', playtimeMinutes: 0, status: 'FINISHED'},
];
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
fetchRoutes({
'GET /api/users/me': json(200, {settings: {}}),
'GET /api/rewards/wallet': json(200, {balance: 0, catalog: [], owned: [], equipped: {COMPLETED_EFFECT: 'FX_GOLD_SHINE'}, history: []}),
'GET /api/library/games': json(200, {content: games, page: 0, size: 20, totalElements: 2, totalPages: 1}),
'GET /api/library/games-count': json(200, {count: 2, hasSteamId: true}),
});
});
afterEach(() => {
cleanup();
localStorage.clear();
});
describe('GameLibrary completed effect', () => {
it('puts the worn effect on the cover of a COMPLETED game only', async () => {
renderWithProviders(<><WalletLoader/><GameLibrary/></>, {entries: ['/library']});
const cover = async (name) => (await screen.findByText(name)).closest('li').querySelector('img').parentElement;
await waitFor(async () => expect((await cover('Portal 2')).classList.contains('fx-cover-gold-shine')).toBe(true));
expect((await cover('Half-Life 2')).classList.contains('fx-cover-gold-shine')).toBe(false);
});
});
+5 -1
View File
@@ -20,6 +20,8 @@ import GameCover from '../components/GameCover.jsx';
import LibraryTabs from '../components/LibraryTabs.jsx';
import GameStatusBadge from '../components/GameStatusBadge.jsx';
import PageContainer from '../components/PageContainer.jsx';
import {usePreferences} from '../context/PreferencesContext.jsx';
import {coverEffect} from '../utils/rewards.js';
import {apiFetch, request} from '../utils/api.js';
import useAbortPrevious from '../hooks/useAbortPrevious.js';
import {platformLabel} from '../utils/platforms.js';
@@ -41,6 +43,7 @@ const SORT_OPTIONS = [
export default function GameLibrary() {
const {t} = useTranslation();
const {equipped} = usePreferences();
const [searchParams, setSearchParams] = useSearchParams();
const page = Math.max(0, parseInt(searchParams.get('page') ?? '0', 10) || 0);
@@ -286,7 +289,8 @@ export default function GameLibrary() {
to={`/library/${g.appid}`}
className="group flex items-center gap-3 sm:gap-4 px-4 sm:px-5 py-3 transition-colors hover:bg-muted/50"
>
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-16 sm:w-24 shrink-0"/>
<GameCover appid={g.appid} imageUrl={g.imageUrl} fx={coverEffect(equipped, g.status)}
className="w-16 sm:w-24 shrink-0"/>
<span className="min-w-0 flex-1 truncate font-medium group-hover:text-primary transition-colors">
{g.name ?? `App ${g.appid}`}
</span>
+18 -1
View File
@@ -1,3 +1,4 @@
import {useRef} from 'react';
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {Coins} from 'lucide-react';
@@ -8,8 +9,10 @@ import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {Skeleton} from '@/components/ui/skeleton';
import {cn} from '@/lib/utils';
import EffectBackground from '../components/EffectBackground.jsx';
import PageContainer from '../components/PageContainer.jsx';
import {usePreferences} from '../context/PreferencesContext.jsx';
import useInView from '../hooks/useInView.js';
import {formatDate} from '../utils/format.js';
import {buyItem, equipItem, LOOKS, SLOTS} from '../utils/rewards.js';
@@ -143,9 +146,13 @@ export default function PointsPage() {
);
}
// A sample of the look: a ring on a circle, a pill with the badge icon, a color dot or a framed box.
// A sample of the look: a ring on a circle, a pill with the badge icon, a color dot, a framed box, a tile with its
// effect or a box with the animated background.
function Preview({code}) {
const look = LOOKS[code];
const ref = useRef(null);
// The shop shows 20 animated previews at once; the ones off screen hold their frame.
const inView = useInView(ref, !!(look?.cover || look?.effect));
if (!look) return null;
if (look.Icon) {
return <span className={cn('flex size-8 items-center justify-center rounded-full', look.className)}><look.Icon className="size-4"/></span>;
@@ -153,5 +160,15 @@ function Preview({code}) {
if (look.accent) {
return <span data-accent={look.accent} className="size-8 rounded-full bg-primary"/>;
}
if (look.cover) {
return <span ref={ref} className={cn('relative h-8 w-14 shrink-0 overflow-hidden rounded-md bg-muted', look.cover, !inView && 'fx-paused')}/>;
}
if (look.effect) {
return (
<span ref={ref} className={cn('relative h-8 w-14 shrink-0 overflow-hidden rounded-md bg-background', !inView && 'fx-paused')}>
<EffectBackground effect={look.effect} className="absolute inset-0"/>
</span>
);
}
return <span className={cn('size-8 shrink-0 bg-muted', look.className, code.startsWith('BORDER') ? 'rounded-full' : 'rounded-md')}/>;
}
+40 -1
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, screen, waitFor, within} from '@testing-library/react';
import {act, cleanup, fireEvent, screen, waitFor, within} from '@testing-library/react';
import {toast} from 'sonner';
import PointsPage from './PointsPage.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
@@ -16,6 +16,9 @@ const wallet = (extra = {}) => ({
{code: 'BORDER_GOLD', slot: 'AVATAR_BORDER', price: 150},
{code: 'BORDER_NEON', slot: 'AVATAR_BORDER', price: 200},
{code: 'BADGE_PILE_SLAYER', slot: 'BADGE', price: 100},
{code: 'FX_GOLD_SHINE', slot: 'COMPLETED_EFFECT', price: 300},
{code: 'BG_GRID', slot: 'PAGE_BACKGROUND', price: 450},
{code: 'BN_GRID', slot: 'BANNER_BACKGROUND', price: 300},
],
owned: ['BADGE_PILE_SLAYER'],
equipped: {},
@@ -47,6 +50,42 @@ afterEach(() => {
});
describe('PointsPage', () => {
it('shows the effect slots with a preview of each look', async () => {
await renderPage();
const card = (slot) => screen.getByText(`POINTS.SLOTS.${slot}`).closest('[data-slot="card"]');
const cover = within(card('COMPLETED_EFFECT')).getByText('POINTS.ITEMS.FX_GOLD_SHINE').closest('li').firstChild;
expect(cover.classList.contains('fx-cover-gold-shine')).toBe(true);
expect(card('PAGE_BACKGROUND').querySelector('[data-effect="grid"]')).not.toBeNull();
expect(card('BANNER_BACKGROUND').querySelector('[data-effect="grid"]')).not.toBeNull();
});
it('holds the frame of previews that are off screen', async () => {
const callbacks = [];
vi.stubGlobal('IntersectionObserver', class {
constructor(callback) { callbacks.push(callback); }
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
});
try {
await renderPage();
const card = (slot) => screen.getByText(`POINTS.SLOTS.${slot}`).closest('[data-slot="card"]');
const cover = within(card('COMPLETED_EFFECT')).getByText('POINTS.ITEMS.FX_GOLD_SHINE').closest('li').firstChild;
const banner = card('BANNER_BACKGROUND').querySelector('[data-effect="grid"]').parentElement;
expect(cover.classList.contains('fx-paused')).toBe(false);
act(() => callbacks[0]([{target: cover, isIntersecting: false}, {target: banner, isIntersecting: false}]));
expect(cover.classList.contains('fx-paused')).toBe(true);
expect(banner.classList.contains('fx-paused')).toBe(true);
act(() => callbacks[0]([{target: cover, isIntersecting: true}]));
expect(cover.classList.contains('fx-paused')).toBe(false);
} finally {
vi.unstubAllGlobals();
}
});
it('groups the catalog by slot and disables Buy below the price', async () => {
await renderPage();
+7 -6
View File
@@ -10,7 +10,7 @@ import {cn} from '@/lib/utils';
import GameCover from '../components/GameCover.jsx';
import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx';
import {formatDay} from '../utils/format.js';
import {equippedAccent, lookClass} from '../utils/rewards.js';
import {backgroundEffect, bannerEffect, coverEffect, equippedAccent, lookClass} from '../utils/rewards.js';
import PointsBadge from '../components/PointsBadge.jsx';
import {GAME_STATUSES} from '../utils/limits.js';
import {formatPlaytime} from '../utils/games.js';
@@ -135,7 +135,7 @@ export default function PublicProfilePage() {
return library.playing?.length > 0
&& <Playing games={library.playing.slice(0, PLAYING_GAMES[size])} compact={size === 'S'}/>;
case 'FINISHED':
return library.finished?.length > 0 && <Finished games={library.finished.slice(0, FINISHED_GAMES[size])}/>;
return library.finished?.length > 0 && <Finished games={library.finished.slice(0, FINISHED_GAMES[size])} equipped={equipped}/>;
default: return statGroupTile(id, library.stats, false);
}
};
@@ -153,10 +153,10 @@ export default function PublicProfilePage() {
// The owner's accent and corners apply to this page only; light or dark mode stays the viewer's.
return (
<div data-accent={equippedAccent(equipped) ?? style.accent} data-corners={style.corners}>
<PageBackground appid={style.backgroundAppid}/>
<PageBackground appid={style.backgroundAppid} effect={backgroundEffect(equipped)}/>
<PageContainer className="p-4 md:p-8 space-y-6">
<Card className={cn('relative overflow-hidden p-6 md:p-8', lookClass(equipped, 'BANNER_FRAME'))}>
<HeroBanner appid={style.bannerAppid}/>
<HeroBanner appid={style.bannerAppid} effect={bannerEffect(equipped)}/>
<div className="relative">
<p className="text-sm font-medium text-muted-foreground">{t('PROFILE.TITLE')}</p>
<h1 className="mt-1 text-3xl md:text-4xl font-bold tracking-tight break-words">{profile.displayName}</h1>
@@ -247,7 +247,7 @@ function Playing({games, compact}) {
);
}
function Finished({games}) {
function Finished({games, equipped}) {
const {t} = useTranslation();
return (
<Card className="gap-0 py-0">
@@ -257,7 +257,8 @@ function Finished({games}) {
<ul className="divide-y">
{games.map((g) => (
<li key={g.appid} className="flex items-center gap-3 sm:gap-4 px-4 sm:px-5 py-3">
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-16 sm:w-24 shrink-0"/>
<GameCover appid={g.appid} imageUrl={g.imageUrl} fx={coverEffect(equipped, g.status)}
className="w-16 sm:w-24 shrink-0"/>
<span className="min-w-0 flex-1 truncate font-medium">{g.name ?? `App ${g.appid}`}</span>
{g.rating && (
<span className="flex items-center gap-0.5" aria-label={t('BACKLOG.RATING_STARS', {count: g.rating})}>
+11
View File
@@ -44,6 +44,17 @@ describe('PublicProfilePage', () => {
expect(fetch.mock.calls[2][1].headers['X-Profile-Ticket']).toBe('the-ticket');
});
it('wears the effects of the owner: background, banner and the cover of a COMPLETED game', async () => {
mockBackends({style: {accent: 'red'}, equipped: {
PAGE_BACKGROUND: 'BG_STARFIELD', BANNER_BACKGROUND: 'BN_WAVES', COMPLETED_EFFECT: 'FX_NEON_PULSE'}});
const {container} = renderPage();
expect(await screen.findByText('Portal 2')).toBeTruthy();
await waitFor(() => expect(container.querySelector('[data-effect="starfield"]')).not.toBeNull());
expect(screen.getByText('Lobo').closest('[data-slot="card"]').querySelector('[data-effect="waves"]')).not.toBeNull();
expect(screen.getByText('Portal 2').closest('li').querySelector('.fx-cover-neon-pulse')).not.toBeNull();
});
it('still shows the profile when the looks call fails', async () => {
mockBackends({style: {accent: 'red'}, rewardsStatus: 500});
renderPage();
+34 -1
View File
@@ -31,6 +31,7 @@ const authRoutes = (me) => ({
'PUT /api/users/settings/appearance': noBody,
'PUT /api/users/settings/profile-style': noBody,
'PUT /api/users/settings/favorite-platform': noBody,
'PUT /api/users/settings/platform-mails': noBody,
'GET /api/version': json(404, {}),
});
@@ -186,11 +187,20 @@ describe('SettingsPage appearance', () => {
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(appearancePuts()[1]).toEqual({accent: 'green', corners: 'round', wideLayout: false, backgroundAppid: null, bannerAppid: null, showBanner: true, reduceMotion: 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('saves the reduce animations switch', async () => {
renderPage(profile());
fireEvent.click(await screen.findByRole('switch', {name: 'SETTINGS.REDUCE_MOTION'}));
await waitFor(() => expect(appearancePuts()).toHaveLength(1));
expect(appearancePuts()[0].reduceMotion).toBe(true);
});
it('removes the background', async () => {
localStorage.setItem('tbp_appearance', JSON.stringify({backgroundAppid: 620}));
renderPage(profile());
@@ -247,6 +257,29 @@ describe('SettingsPage favorite platform', () => {
});
});
describe('SettingsPage platform mails', () => {
beforeEach(() => localStorage.setItem('tbp_session', '1'));
afterEach(() => {
cleanup();
localStorage.clear();
});
it('is on by default and saves the opt-out', async () => {
toast.success.mockClear();
renderPage(profile());
const mails = await screen.findByRole('switch', {name: 'SETTINGS.PLATFORM_MAILS'});
expect(mails.getAttribute('aria-checked')).toBe('true');
fireEvent.click(mails);
await waitFor(() => expect(puts()).toEqual([{enabled: false}]));
expect(fetch.mock.calls.find(([, init]) => init?.method === 'PUT')[0]).toContain('/api/users/settings/platform-mails');
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('SETTINGS.SAVED'));
expect(mails.getAttribute('aria-checked')).toBe('false');
});
});
describe('SettingsPage session', () => {
beforeEach(() => localStorage.setItem('tbp_session', '1'));
+1
View File
@@ -1,5 +1,6 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "./effects.css";
@import "@fontsource-variable/inter";
@custom-variant dark (&:is(.dark *));
+9
View File
@@ -0,0 +1,9 @@
import {useEffect} from 'react';
import {usePreferences} from '../context/PreferencesContext.jsx';
/** Loads the preferences and the wallet like App does after the login, so a page under test sees the worn looks. */
export default function WalletLoader() {
const {syncPreferences} = usePreferences();
useEffect(() => { syncPreferences(); }, [syncPreferences]);
return null;
}
+8
View File
@@ -9,6 +9,7 @@ export const CORNERS = ['sharp', 'default', 'round'];
// bannerAppid null: the dashboard banner shows the game played most in the last two weeks.
export const DEFAULT_APPEARANCE = {
accent: 'violet', corners: 'default', wideLayout: false, backgroundAppid: null, bannerAppid: null, showBanner: true,
reduceMotion: false,
};
export const appid = (value) => (Number.isInteger(value) && value > 0 ? value : null);
@@ -22,6 +23,7 @@ export const normalizeAppearance = (value) => ({
backgroundAppid: appid(value?.backgroundAppid),
bannerAppid: appid(value?.bannerAppid),
showBanner: value?.showBanner !== false,
reduceMotion: value?.reduceMotion === true,
});
/**
@@ -41,8 +43,14 @@ export const cacheAppearance = (appearance) => storage.set(STORAGE_KEY, JSON.str
export const applyAppearance = (appearance, accentOverride = null, root = document.documentElement) => {
root.dataset.accent = accentOverride ?? appearance.accent;
root.dataset.corners = appearance.corners;
root.toggleAttribute('data-reduce-motion', appearance.reduceMotion === true);
};
/** True when the system asks for reduced motion or the "Reduce animations" setting is on. */
export const reduceMotion = () =>
(window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false)
|| document.documentElement.hasAttribute('data-reduce-motion');
/**
* PUT /api/users/settings/appearance on Auth. The caller handles the response status.
*/
+44 -5
View File
@@ -1,22 +1,23 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it} from 'vitest';
import {afterEach, describe, expect, it, vi} from 'vitest';
import {
applyAppearance,
cacheAppearance,
DEFAULT_APPEARANCE,
normalizeAppearance,
readCachedAppearance,
reduceMotion,
} from './appearance.js';
describe('normalizeAppearance', () => {
it('keeps known values', () => {
const appearance = {accent: 'blue', corners: 'round', wideLayout: true, backgroundAppid: 620, bannerAppid: 1086940, showBanner: false};
const appearance = {accent: 'blue', corners: 'round', wideLayout: true, backgroundAppid: 620, bannerAppid: 1086940, showBanner: false, reduceMotion: true};
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, bannerAppid: '620', showBanner: 'no'}))
expect(normalizeAppearance({accent: 'red;x', corners: 'pointy', wideLayout: 'yes', backgroundAppid: -1, bannerAppid: '620', showBanner: 'no', reduceMotion: 'yes'}))
.toEqual(DEFAULT_APPEARANCE);
expect(normalizeAppearance(null)).toEqual(DEFAULT_APPEARANCE);
expect(normalizeAppearance({backgroundAppid: 1.5})).toEqual(DEFAULT_APPEARANCE);
@@ -27,9 +28,9 @@ describe('appearance cache', () => {
afterEach(() => localStorage.clear());
it('reads back what was cached', () => {
cacheAppearance({accent: 'green', corners: 'sharp', wideLayout: false, backgroundAppid: null, bannerAppid: 620, showBanner: true});
cacheAppearance({accent: 'green', corners: 'sharp', wideLayout: false, backgroundAppid: null, bannerAppid: 620, showBanner: true, reduceMotion: false});
expect(readCachedAppearance()).toEqual({accent: 'green', corners: 'sharp', wideLayout: false, backgroundAppid: null, bannerAppid: 620, showBanner: true});
expect(readCachedAppearance()).toEqual({accent: 'green', corners: 'sharp', wideLayout: false, backgroundAppid: null, bannerAppid: 620, showBanner: true, reduceMotion: false});
});
it('falls back to the defaults for a broken or empty cache', () => {
@@ -58,3 +59,41 @@ describe('applyAppearance', () => {
expect(root.dataset.accent).toBe('gold');
});
});
describe('reduce motion', () => {
afterEach(() => {
document.documentElement.removeAttribute('data-reduce-motion');
vi.unstubAllGlobals();
});
const mediaQuery = (matches) => vi.stubGlobal('matchMedia', () => ({matches}));
it('is set on the root by applyAppearance and removed again', () => {
const root = document.createElement('html');
applyAppearance({accent: 'blue', corners: 'default', reduceMotion: true}, null, root);
expect(root.hasAttribute('data-reduce-motion')).toBe(true);
applyAppearance({accent: 'blue', corners: 'default', reduceMotion: false}, null, root);
expect(root.hasAttribute('data-reduce-motion')).toBe(false);
});
it('is on for the system media query', () => {
mediaQuery(true);
expect(reduceMotion()).toBe(true);
});
it('is on for the setting', () => {
mediaQuery(false);
document.documentElement.setAttribute('data-reduce-motion', '');
expect(reduceMotion()).toBe(true);
});
it('is off for neither', () => {
mediaQuery(false);
expect(reduceMotion()).toBe(false);
});
});
+30
View File
@@ -0,0 +1,30 @@
import {apiFetch} from './api.js';
const AUTH = () => import.meta.env.VITE_API_URL;
const JSON_HEADERS = {'Content-Type': 'application/json'};
/**
* PLATFORM_ADMIN only (403 otherwise): {results: [{id, subjectEn, subjectDe, bodyEn, bodyDe, createdBy, createdAt,
* recipientCount, sentCount, failedCount}]}, newest first.
*/
export const fetchBroadcasts = () => apiFetch(`${AUTH()}/api/admin/broadcasts`);
/**
* {count}: the active users with platform mails on, among `userIds`; all of them when `userIds` is null.
*/
export const fetchRecipientCount = (userIds) =>
apiFetch(`${AUTH()}/api/admin/broadcasts/recipient-count${userIds ? `?userIds=${userIds.join(',')}` : ''}`);
/**
* `broadcast` is {subjectEn, subjectDe, bodyEn, bodyDe, userIds}. 201 {recipientCount}, 409 NO_RECIPIENTS, or 400 for a
* blank or too long field.
*/
export const sendBroadcast = (broadcast) =>
apiFetch(`${AUTH()}/api/admin/broadcasts`, {method: 'POST', headers: JSON_HEADERS, body: JSON.stringify(broadcast)});
/**
* 204: the draft goes to the admin's own address only, in the admin's language.
*/
export const sendTestBroadcast = (broadcast) =>
apiFetch(`${AUTH()}/api/admin/broadcasts/test`,
{method: 'POST', headers: JSON_HEADERS, body: JSON.stringify(broadcast)});
+3
View File
@@ -33,3 +33,6 @@ export const MAX_HINT_LENGTH = 500;
// Auth NewsRequest @Size(max = 100) and @Size(max = 2000)
export const MAX_NEWS_TITLE_LENGTH = 100;
export const MAX_NEWS_TEXT_LENGTH = 2000;
// Auth BroadcastRequest @Size(max = 150) and @Size(max = 5000)
export const MAX_BROADCAST_SUBJECT_LENGTH = 150;
export const MAX_BROADCAST_BODY_LENGTH = 5000;
+53 -1
View File
@@ -1,10 +1,11 @@
import {Swords, Trophy} from 'lucide-react';
import {apiFetch, request} from './api.js';
import {storage} from './storage.js';
const REWARDS_URL = `${import.meta.env.VITE_CORE_API_URL}/api/rewards`;
const JSON_HEADERS = {'Content-Type': 'application/json'};
export const SLOTS = ['AVATAR_BORDER', 'BADGE', 'ACCENT', 'BANNER_FRAME'];
export const SLOTS = ['AVATAR_BORDER', 'BADGE', 'ACCENT', 'BANNER_FRAME', 'COMPLETED_EFFECT', 'PAGE_BACKGROUND', 'BANNER_BACKGROUND'];
// The shop catalog of Core (ShopItem) as looks: only these classes are ever applied, never a string from the server.
// An unknown code is skipped.
@@ -17,6 +18,28 @@ export const LOOKS = {
ACCENT_TEAL: {accent: 'teal'},
FRAME_GOLD: {className: 'ring-2 ring-amber-400 ring-offset-2 ring-offset-background'},
FRAME_NEON: {className: 'ring-2 ring-fuchsia-500 ring-offset-2 ring-offset-background shadow-[0_0_14px] shadow-fuchsia-500/60'},
// Completed effects (effects.css): `cover` is the class set of a tile, `detail` the decoration of the details page.
FX_GOLD_SHINE: {cover: 'fx fx-cover-gold-shine outline outline-2 -outline-offset-2 outline-amber-400', detail: 'fx-detail-gold-shine'},
FX_NEON_PULSE: {cover: 'fx fx-cover-neon-pulse outline outline-2 -outline-offset-2 outline-primary', detail: 'fx-detail-neon-pulse'},
FX_HOLO: {cover: 'fx fx-cover-holo', detail: 'fx-detail-holo'},
FX_EMBER: {cover: 'fx fx-cover-ember', detail: 'fx-detail-ember'},
// Page and banner backgrounds: BG_ and BN_ of a name share one engine (components/EffectBackground.jsx).
BG_TRIANGLES: {effect: 'triangles'},
BG_STARFIELD: {effect: 'starfield'},
BG_PIXELS: {effect: 'pixels'},
BG_AURORA: {effect: 'aurora'},
BG_MESH: {effect: 'mesh'},
BG_GRID: {effect: 'grid'},
BG_WAVES: {effect: 'waves'},
BG_BOKEH: {effect: 'bokeh'},
BN_TRIANGLES: {effect: 'triangles'},
BN_STARFIELD: {effect: 'starfield'},
BN_PIXELS: {effect: 'pixels'},
BN_AURORA: {effect: 'aurora'},
BN_MESH: {effect: 'mesh'},
BN_GRID: {effect: 'grid'},
BN_WAVES: {effect: 'waves'},
BN_BOKEH: {effect: 'bokeh'},
};
/** The premium accent worn, or null (then the appearance accent applies). */
@@ -25,12 +48,41 @@ export const equippedAccent = (equipped) => LOOKS[equipped?.ACCENT]?.accent ?? n
/** The classes of the item worn in a slot, or undefined. */
export const lookClass = (equipped, slot) => LOOKS[equipped?.[slot]]?.className;
// Only a COMPLETED game shows the completed effect (FINISHED is not 100 %).
const completedLook = (equipped, status) => (status === 'COMPLETED' ? LOOKS[equipped?.COMPLETED_EFFECT] : undefined);
/** The cover classes of the completed effect worn, or undefined. */
export const coverEffect = (equipped, status) => completedLook(equipped, status)?.cover;
/** The details page decoration class of the completed effect worn, or undefined. */
export const detailEffect = (equipped, status) => completedLook(equipped, status)?.detail;
/** The effect name of the page background worn, or null (then the game art applies). */
export const backgroundEffect = (equipped) => LOOKS[equipped?.PAGE_BACKGROUND]?.effect ?? null;
/** The effect name of the banner background worn, or null. */
export const bannerEffect = (equipped) => LOOKS[equipped?.BANNER_BACKGROUND]?.effect ?? null;
/** The badge worn: `{code, Icon, className}` or null. */
export const equippedBadge = (equipped) => {
const look = LOOKS[equipped?.BADGE];
return look?.Icon ? {code: equipped.BADGE, ...look} : null;
};
const EQUIPPED_STORAGE_KEY = 'tbp_equipped';
/** The last known worn looks, so the first paint does not flash the item 10 art before the wallet answers. */
export const readCachedEquipped = () => {
try {
const value = JSON.parse(storage.get(EQUIPPED_STORAGE_KEY));
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
} catch {
return {};
}
};
export const cacheEquipped = (equipped) => storage.set(EQUIPPED_STORAGE_KEY, JSON.stringify(equipped));
/** GET /api/rewards/wallet on Core: `{ok, status, data}`. Data is the balance, catalog, owned codes, worn looks and history. */
export const loadWallet = () => request(`${REWARDS_URL}/wallet`);
+26
View File
@@ -0,0 +1,26 @@
import {describe, expect, it} from 'vitest';
import {backgroundEffect, bannerEffect, coverEffect, detailEffect, LOOKS} from './rewards.js';
describe('effects worn', () => {
const equipped = {COMPLETED_EFFECT: 'FX_GOLD_SHINE', PAGE_BACKGROUND: 'BG_STARFIELD', BANNER_BACKGROUND: 'BN_WAVES'};
it('shows the completed effect only on a COMPLETED game', () => {
expect(coverEffect(equipped, 'COMPLETED')).toContain('fx-cover-gold-shine');
expect(detailEffect(equipped, 'COMPLETED')).toBe('fx-detail-gold-shine');
expect(coverEffect(equipped, 'FINISHED')).toBeUndefined();
expect(detailEffect(equipped, 'FINISHED')).toBeUndefined();
});
it('ignores an unknown or missing code', () => {
expect(coverEffect({COMPLETED_EFFECT: 'FX_FUTURE'}, 'COMPLETED')).toBeUndefined();
expect(coverEffect(undefined, 'COMPLETED')).toBeUndefined();
expect(backgroundEffect({PAGE_BACKGROUND: 'BG_FUTURE'})).toBeNull();
expect(bannerEffect({})).toBeNull();
});
it('maps a background and a banner of one name to the same engine', () => {
expect(backgroundEffect(equipped)).toBe('starfield');
expect(bannerEffect(equipped)).toBe('waves');
expect(LOOKS.BG_GRID.effect).toBe(LOOKS.BN_GRID.effect);
});
});
+8
View File
@@ -58,3 +58,11 @@ export const markWelcomeSeen = () =>
method: 'PUT',
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
/** PUT on Auth, 204: turns the broadcast mails on or off. The caller handles the response status. */
export const savePlatformMails = (enabled) =>
apiFetch(`${import.meta.env.VITE_API_URL}/api/users/settings/platform-mails`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({enabled}),
});