Public Access
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
249973dd69 | ||
|
|
dd45c0b5f6 | ||
|
|
2ec5833f29 | ||
|
|
fa0857f55d |
No files matched your search
@@ -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;
|
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.
|
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
|
- **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
|
streak bonuses; Core checks proof of play), spent in a small shop on an avatar border, a badge, a premium accent, a
|
||||||
banner frame, shown on the dashboard and the public profile. The balance is in the header.
|
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
|
- **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.
|
finished games per year of all users.
|
||||||
- **Library**: status filters, "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest
|
- **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.
|
every achievement is unlocked.
|
||||||
- **Confetti**: opening a finished game plays a small confetti burst, a finished game with every achievement a big
|
- **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),
|
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
|
- **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
|
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".
|
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
|
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
|
"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.
|
("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.
|
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
|
- **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
|
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"
|
- **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`
|
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`.
|
(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
|
- **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
|
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
|
`/me` and the library summary. "Let's go" and "Skip" stamp it in Auth and open the dashboard. The settings page
|
||||||
|
|||||||
+60
-3
@@ -89,6 +89,8 @@
|
|||||||
},
|
},
|
||||||
"WIDE_LAYOUT": "Breites Layout",
|
"WIDE_LAYOUT": "Breites Layout",
|
||||||
"WIDE_LAYOUT_HINT": "Nutzt mehr Platz auf breiten Bildschirmen.",
|
"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": "Hintergrund",
|
||||||
"BACKGROUND_HINT": "Öffne ein Spiel in deiner Bibliothek und wähle „Als Hintergrund“.",
|
"BACKGROUND_HINT": "Öffne ein Spiel in deiner Bibliothek und wähle „Als Hintergrund“.",
|
||||||
"BACKGROUND_REMOVE": "Hintergrund entfernen",
|
"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\".",
|
"PROFILE_TILES_HINT": "Wähle, was dein Profil zeigt und wo: öffne dein Profil und klicke auf \"Anpassen\".",
|
||||||
"FAVORITE_PLATFORM": "Lieblingsplattform",
|
"FAVORITE_PLATFORM": "Lieblingsplattform",
|
||||||
"FAVORITE_PLATFORM_HINT": "Wird zuerst gewählt, wenn du ein Spiel für mehrere Plattformen hinzufügst.",
|
"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": {
|
"VALIDATION": {
|
||||||
"EMAIL_REQUIRED": "Email ist erforderlich.",
|
"EMAIL_REQUIRED": "Email ist erforderlich.",
|
||||||
@@ -304,6 +308,7 @@
|
|||||||
"ADMIN_HINTS": "Hinweise",
|
"ADMIN_HINTS": "Hinweise",
|
||||||
"WHATS_NEW": "Neuigkeiten",
|
"WHATS_NEW": "Neuigkeiten",
|
||||||
"ADMIN_NEWS": "Neuigkeiten verwalten",
|
"ADMIN_NEWS": "Neuigkeiten verwalten",
|
||||||
|
"ADMIN_BROADCASTS": "Rundmail",
|
||||||
"PENDING_REQUESTS_one": "{{count}} Registrierung wartet",
|
"PENDING_REQUESTS_one": "{{count}} Registrierung wartet",
|
||||||
"PENDING_REQUESTS_other": "{{count}} Registrierungen warten"
|
"PENDING_REQUESTS_other": "{{count}} Registrierungen warten"
|
||||||
},
|
},
|
||||||
@@ -691,6 +696,35 @@
|
|||||||
"LOAD_ERROR": "Die Einträge konnten nicht geladen werden.",
|
"LOAD_ERROR": "Die Einträge konnten nicht geladen werden.",
|
||||||
"NO_ACCESS": "Nur Admins können diese Seite öffnen."
|
"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": {
|
"TILES": {
|
||||||
"CUSTOMIZE": "Anpassen",
|
"CUSTOMIZE": "Anpassen",
|
||||||
"EDIT_HINT": "Verschiebe, vergrößere oder verstecke die Kacheln. Gespeichert wird erst mit \"Fertig\".",
|
"EDIT_HINT": "Verschiebe, vergrößere oder verstecke die Kacheln. Gespeichert wird erst mit \"Fertig\".",
|
||||||
@@ -826,7 +860,10 @@
|
|||||||
"AVATAR_BORDER": "Avatarrahmen",
|
"AVATAR_BORDER": "Avatarrahmen",
|
||||||
"BADGE": "Abzeichen",
|
"BADGE": "Abzeichen",
|
||||||
"ACCENT": "Akzent",
|
"ACCENT": "Akzent",
|
||||||
"BANNER_FRAME": "Bannerrahmen"
|
"BANNER_FRAME": "Bannerrahmen",
|
||||||
|
"COMPLETED_EFFECT": "Effekt für 100 %",
|
||||||
|
"PAGE_BACKGROUND": "Seitenhintergrund",
|
||||||
|
"BANNER_BACKGROUND": "Bannerhintergrund"
|
||||||
},
|
},
|
||||||
"ITEMS": {
|
"ITEMS": {
|
||||||
"BORDER_GOLD": "Goldener Avatarrahmen",
|
"BORDER_GOLD": "Goldener Avatarrahmen",
|
||||||
@@ -836,7 +873,27 @@
|
|||||||
"ACCENT_GOLD": "Akzent Gold",
|
"ACCENT_GOLD": "Akzent Gold",
|
||||||
"ACCENT_TEAL": "Akzent Türkis",
|
"ACCENT_TEAL": "Akzent Türkis",
|
||||||
"FRAME_GOLD": "Goldener Bannerrahmen",
|
"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": {
|
"KINDS": {
|
||||||
"STARTED": "Gestartet",
|
"STARTED": "Gestartet",
|
||||||
|
|||||||
+60
-3
@@ -89,6 +89,8 @@
|
|||||||
},
|
},
|
||||||
"WIDE_LAYOUT": "Wide layout",
|
"WIDE_LAYOUT": "Wide layout",
|
||||||
"WIDE_LAYOUT_HINT": "Uses more of a wide screen.",
|
"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": "Background",
|
||||||
"BACKGROUND_HINT": "Open a game in your library and choose \"Use as background\".",
|
"BACKGROUND_HINT": "Open a game in your library and choose \"Use as background\".",
|
||||||
"BACKGROUND_REMOVE": "Remove 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\".",
|
"PROFILE_TILES_HINT": "Choose what your profile shows, and where: open your profile and click \"Customize\".",
|
||||||
"FAVORITE_PLATFORM": "Favorite platform",
|
"FAVORITE_PLATFORM": "Favorite platform",
|
||||||
"FAVORITE_PLATFORM_HINT": "Selected first when you add a game on more than one 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": {
|
"VALIDATION": {
|
||||||
"EMAIL_REQUIRED": "Email is required.",
|
"EMAIL_REQUIRED": "Email is required.",
|
||||||
@@ -304,6 +308,7 @@
|
|||||||
"ADMIN_HINTS": "Hints",
|
"ADMIN_HINTS": "Hints",
|
||||||
"WHATS_NEW": "What's new",
|
"WHATS_NEW": "What's new",
|
||||||
"ADMIN_NEWS": "News",
|
"ADMIN_NEWS": "News",
|
||||||
|
"ADMIN_BROADCASTS": "Mail",
|
||||||
"PENDING_REQUESTS_one": "{{count}} registration waits",
|
"PENDING_REQUESTS_one": "{{count}} registration waits",
|
||||||
"PENDING_REQUESTS_other": "{{count}} registrations wait"
|
"PENDING_REQUESTS_other": "{{count}} registrations wait"
|
||||||
},
|
},
|
||||||
@@ -691,6 +696,35 @@
|
|||||||
"LOAD_ERROR": "The entries could not be loaded.",
|
"LOAD_ERROR": "The entries could not be loaded.",
|
||||||
"NO_ACCESS": "Only admins can open this page."
|
"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": {
|
"TILES": {
|
||||||
"CUSTOMIZE": "Customize",
|
"CUSTOMIZE": "Customize",
|
||||||
"EDIT_HINT": "Move, resize or hide the tiles. Nothing is saved before \"Done\".",
|
"EDIT_HINT": "Move, resize or hide the tiles. Nothing is saved before \"Done\".",
|
||||||
@@ -826,7 +860,10 @@
|
|||||||
"AVATAR_BORDER": "Avatar border",
|
"AVATAR_BORDER": "Avatar border",
|
||||||
"BADGE": "Badge",
|
"BADGE": "Badge",
|
||||||
"ACCENT": "Accent",
|
"ACCENT": "Accent",
|
||||||
"BANNER_FRAME": "Banner frame"
|
"BANNER_FRAME": "Banner frame",
|
||||||
|
"COMPLETED_EFFECT": "Completed effect",
|
||||||
|
"PAGE_BACKGROUND": "Page background",
|
||||||
|
"BANNER_BACKGROUND": "Banner background"
|
||||||
},
|
},
|
||||||
"ITEMS": {
|
"ITEMS": {
|
||||||
"BORDER_GOLD": "Golden avatar border",
|
"BORDER_GOLD": "Golden avatar border",
|
||||||
@@ -836,7 +873,27 @@
|
|||||||
"ACCENT_GOLD": "Gold accent",
|
"ACCENT_GOLD": "Gold accent",
|
||||||
"ACCENT_TEAL": "Teal accent",
|
"ACCENT_TEAL": "Teal accent",
|
||||||
"FRAME_GOLD": "Golden banner frame",
|
"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": {
|
"KINDS": {
|
||||||
"STARTED": "Started",
|
"STARTED": "Started",
|
||||||
|
|||||||
+7
-2
@@ -9,6 +9,7 @@ import Dashboard from "./pages/Dashboard.jsx";
|
|||||||
import RegistrationPage from './pages/RegistrationPage.jsx';
|
import RegistrationPage from './pages/RegistrationPage.jsx';
|
||||||
import SettingsPage from './pages/SettingsPage.jsx';
|
import SettingsPage from './pages/SettingsPage.jsx';
|
||||||
import AdminHintsPage from './pages/AdminHintsPage.jsx';
|
import AdminHintsPage from './pages/AdminHintsPage.jsx';
|
||||||
|
import AdminBroadcastPage from './pages/AdminBroadcastPage.jsx';
|
||||||
import AdminNewsPage from './pages/AdminNewsPage.jsx';
|
import AdminNewsPage from './pages/AdminNewsPage.jsx';
|
||||||
import WhatsNewPage from './pages/WhatsNewPage.jsx';
|
import WhatsNewPage from './pages/WhatsNewPage.jsx';
|
||||||
import WelcomePage from './pages/WelcomePage.jsx';
|
import WelcomePage from './pages/WelcomePage.jsx';
|
||||||
@@ -28,6 +29,7 @@ import {Toaster} from '@/components/ui/sonner';
|
|||||||
import {TooltipProvider} from '@/components/ui/tooltip';
|
import {TooltipProvider} from '@/components/ui/tooltip';
|
||||||
import PageBackground from './components/PageBackground.jsx';
|
import PageBackground from './components/PageBackground.jsx';
|
||||||
import {usePreferences} from './context/PreferencesContext.jsx';
|
import {usePreferences} from './context/PreferencesContext.jsx';
|
||||||
|
import {backgroundEffect} from './utils/rewards.js';
|
||||||
import {clearSession} from './utils/auth.js';
|
import {clearSession} from './utils/auth.js';
|
||||||
import useSession from './hooks/useSession.js';
|
import useSession from './hooks/useSession.js';
|
||||||
import {UnsavedChangesGuard} from './hooks/useUnsavedChanges.js';
|
import {UnsavedChangesGuard} from './hooks/useUnsavedChanges.js';
|
||||||
@@ -36,7 +38,7 @@ import {logout} from './utils/api.js';
|
|||||||
function App() {
|
function App() {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const {active: isAuthenticated} = useSession();
|
const {active: isAuthenticated} = useSession();
|
||||||
const {appearance, syncPreferences} = usePreferences();
|
const {appearance, equipped, syncPreferences} = usePreferences();
|
||||||
|
|
||||||
// Hooks for routing
|
// Hooks for routing
|
||||||
const navigate = useNavigate();
|
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. */}
|
{/* 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">
|
<div className="isolate min-h-screen flex flex-col bg-background text-foreground font-sans">
|
||||||
{/* A public profile shows the owner's background instead. */}
|
{/* 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}/>
|
<AppHeader isAuthenticated={isAuthenticated} onLogout={handleLogout}/>
|
||||||
|
|
||||||
<main className="grow flex flex-col">
|
<main className="grow flex flex-col">
|
||||||
@@ -82,6 +86,7 @@ function App() {
|
|||||||
<Route path="/admin/users" element={<AdminUsersPage/>}/>
|
<Route path="/admin/users" element={<AdminUsersPage/>}/>
|
||||||
<Route path="/admin/hints" element={<AdminHintsPage/>}/>
|
<Route path="/admin/hints" element={<AdminHintsPage/>}/>
|
||||||
<Route path="/admin/news" element={<AdminNewsPage/>}/>
|
<Route path="/admin/news" element={<AdminNewsPage/>}/>
|
||||||
|
<Route path="/admin/broadcasts" element={<AdminBroadcastPage/>}/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/u/:name" element={<PublicProfilePage/>}/>
|
<Route path="/u/:name" element={<PublicProfilePage/>}/>
|
||||||
<Route path="/impressum" element={<ImprintPage/>}/>
|
<Route path="/impressum" element={<ImprintPage/>}/>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {useEffect, useState} from 'react';
|
import {useEffect, useState} from 'react';
|
||||||
import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom';
|
import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom';
|
||||||
import {useTranslation} from 'react-i18next';
|
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 {toast} from 'sonner';
|
||||||
|
|
||||||
import {Badge} from '@/components/ui/badge';
|
import {Badge} from '@/components/ui/badge';
|
||||||
@@ -169,6 +169,9 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
|||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link to="/admin/news"><Newspaper/>{t('NAV.ADMIN_NEWS')}</Link>
|
<Link to="/admin/news"><Newspaper/>{t('NAV.ADMIN_NEWS')}</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link to="/admin/broadcasts"><Mail/>{t('NAV.ADMIN_BROADCASTS')}</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ describe('AppHeader', () => {
|
|||||||
|
|
||||||
expect(await screen.findByText('NAV.ADMIN')).toBeTruthy();
|
expect(await screen.findByText('NAV.ADMIN')).toBeTruthy();
|
||||||
expect(screen.getByText('NAV.ADMIN_NEWS')).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.getByText('NAV.VIEW_AS.MENU')).toBeTruthy();
|
||||||
expect(screen.queryByText('NAV.VIEW_AS.BADGE')).toBeNull();
|
expect(screen.queryByText('NAV.VIEW_AS.BADGE')).toBeNull();
|
||||||
expect(screen.queryByLabelText('NAV.PENDING_REQUESTS:0')).toBeNull();
|
expect(screen.queryByLabelText('NAV.PENDING_REQUESTS:0')).toBeNull();
|
||||||
@@ -84,6 +85,7 @@ describe('AppHeader', () => {
|
|||||||
openMenu();
|
openMenu();
|
||||||
expect(await screen.findByText('NAV.VIEW_AS.MENU')).toBeTruthy();
|
expect(await screen.findByText('NAV.VIEW_AS.MENU')).toBeTruthy();
|
||||||
expect(screen.queryByText('NAV.ADMIN')).toBeNull();
|
expect(screen.queryByText('NAV.ADMIN')).toBeNull();
|
||||||
|
expect(screen.queryByText('NAV.ADMIN_BROADCASTS')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('offers nothing of that to a user', async () => {
|
it('offers nothing of that to a user', async () => {
|
||||||
@@ -96,6 +98,7 @@ describe('AppHeader', () => {
|
|||||||
expect(screen.getByText('NAV.WHATS_NEW')).toBeTruthy();
|
expect(screen.getByText('NAV.WHATS_NEW')).toBeTruthy();
|
||||||
expect(screen.queryByText('NAV.ADMIN')).toBeNull();
|
expect(screen.queryByText('NAV.ADMIN')).toBeNull();
|
||||||
expect(screen.queryByText('NAV.ADMIN_NEWS')).toBeNull();
|
expect(screen.queryByText('NAV.ADMIN_NEWS')).toBeNull();
|
||||||
|
expect(screen.queryByText('NAV.ADMIN_BROADCASTS')).toBeNull();
|
||||||
expect(screen.queryByText('NAV.VIEW_AS.MENU')).toBeNull();
|
expect(screen.queryByText('NAV.VIEW_AS.MENU')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -31,6 +31,14 @@ export default function AppearanceCard() {
|
|||||||
<Switch id="wideLayout" checked={appearance.wideLayout}
|
<Switch id="wideLayout" checked={appearance.wideLayout}
|
||||||
onCheckedChange={(wideLayout) => saveAppearance({wideLayout})}/>
|
onCheckedChange={(wideLayout) => saveAppearance({wideLayout})}/>
|
||||||
</div>
|
</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="flex items-center justify-between gap-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label id="background-label">{t('SETTINGS.BACKGROUND')}</Label>
|
<Label id="background-label">{t('SETTINGS.BACKGROUND')}</Label>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {useEffect, useRef, useState} from 'react';
|
import {useEffect, useRef, useState} from 'react';
|
||||||
|
|
||||||
import {LOGO_COLORS} from './Logo.jsx';
|
import {LOGO_COLORS} from './Logo.jsx';
|
||||||
|
import {reduceMotion} from '../utils/appearance.js';
|
||||||
|
|
||||||
const DURATION_MS = 2800;
|
const DURATION_MS = 2800;
|
||||||
const PIECES = {small: 70, big: 240};
|
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.
|
// 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 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.
|
// The logo colors of the chosen accent and the accent itself.
|
||||||
function colors() {
|
function colors() {
|
||||||
const style = getComputedStyle(document.documentElement);
|
const style = getComputedStyle(document.documentElement);
|
||||||
@@ -48,7 +47,7 @@ function pieces(size, width, height) {
|
|||||||
*/
|
*/
|
||||||
export default function Confetti({size, onDone}) {
|
export default function Confetti({size, onDone}) {
|
||||||
const canvasRef = useRef(null);
|
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.
|
// 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);
|
const onDoneRef = useRef(onDone);
|
||||||
onDoneRef.current = onDone;
|
onDoneRef.current = onDone;
|
||||||
|
|||||||
@@ -34,4 +34,13 @@ describe('Confetti', () => {
|
|||||||
|
|
||||||
expect(container.querySelector('canvas')).toBeNull();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -11,7 +11,7 @@ import PointsBadge from './PointsBadge.jsx';
|
|||||||
import SteamAvatar from './SteamAvatar.jsx';
|
import SteamAvatar from './SteamAvatar.jsx';
|
||||||
import SyncStatusBadge from './SyncStatusBadge.jsx';
|
import SyncStatusBadge from './SyncStatusBadge.jsx';
|
||||||
import {useCurrentUserContext} from '../context/CurrentUserContext.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}) {
|
export default function DashboardWelcomeCard({bannerAppid, avatarUrl, sync, forceSync, editing, onCustomize, equipped}) {
|
||||||
const {t} = useTranslation();
|
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',
|
<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'))}>
|
lookClass(equipped, 'BANNER_FRAME'))}>
|
||||||
{/* A picked game, else the game played most in the last two weeks. */}
|
{/* 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">
|
<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. */}
|
{/* The slot is there before the avatar loads, so the title does not jump. */}
|
||||||
{hasSteamId && (
|
{hasSteamId && (
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ afterEach(() => {
|
|||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderCard = async (equipped) => {
|
const renderCard = async (equipped, bannerAppid) => {
|
||||||
localStorage.setItem('tbp_session', '1');
|
localStorage.setItem('tbp_session', '1');
|
||||||
fetchRoutes({'GET /api/users/me': json(200, {displayName: 'Lobo', steamId: '1'}), 'GET /api/library/hero-art': json(200, {})});
|
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/);
|
await screen.findByText(/Lobo/);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -29,6 +29,22 @@ describe('DashboardWelcomeCard looks', () => {
|
|||||||
expect(screen.getByText('POINTS.ITEMS.BADGE_COMPLETIONIST')).toBeTruthy();
|
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 () => {
|
it('keeps the default avatar ring without a worn border', async () => {
|
||||||
await renderCard({});
|
await renderCard({});
|
||||||
|
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
}}/>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,23 +1,28 @@
|
|||||||
import {useState} from 'react';
|
import {useRef, useState} from 'react';
|
||||||
import {Gamepad2} from 'lucide-react';
|
import {Gamepad2} from 'lucide-react';
|
||||||
|
|
||||||
import {cn} from '@/lib/utils';
|
import {cn} from '@/lib/utils';
|
||||||
import {coverUrl} from '@/utils/games.js';
|
import {coverUrl} from '@/utils/games.js';
|
||||||
import {COVER, reportBrokenImage} from '@/utils/brokenImages.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.
|
* 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 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);
|
const src = imageUrl ?? coverUrl(appid);
|
||||||
// Per URL: a reused cover (e.g. the next suggestion) loads and reports its own picture.
|
// Per URL: a reused cover (e.g. the next suggestion) loads and reports its own picture.
|
||||||
const [failedSrc, setFailedSrc] = useState(null);
|
const [failedSrc, setFailedSrc] = useState(null);
|
||||||
const failed = failedSrc === src;
|
const failed = failedSrc === src;
|
||||||
|
const ref = useRef(null);
|
||||||
|
const inView = useInView(ref, !!fx);
|
||||||
|
|
||||||
return (
|
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 ? (
|
{failed ? (
|
||||||
<div className="flex size-full items-center justify-center text-muted-foreground">
|
<div className="flex size-full items-center justify-center text-muted-foreground">
|
||||||
<Gamepad2 className="size-1/3 max-h-8"/>
|
<Gamepad2 className="size-1/3 max-h-8"/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
import {cleanup, fireEvent, render} from '@testing-library/react';
|
import {act, cleanup, fireEvent, render} from '@testing-library/react';
|
||||||
import GameCover from './GameCover.jsx';
|
import GameCover from './GameCover.jsx';
|
||||||
import {reportBrokenImage} from '../utils/brokenImages.js';
|
import {reportBrokenImage} from '../utils/brokenImages.js';
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ vi.mock('../utils/brokenImages.js', () => ({COVER: 'COVER', reportBrokenImage: v
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -38,4 +39,69 @@ describe('GameCover', () => {
|
|||||||
fireEvent.error(container.querySelector('img'));
|
fireEvent.error(container.querySelector('img'));
|
||||||
expect(reportBrokenImage).toHaveBeenCalledWith(220, 'COVER');
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -6,6 +6,7 @@ import {toast} from 'sonner';
|
|||||||
import {Badge} from '@/components/ui/badge';
|
import {Badge} from '@/components/ui/badge';
|
||||||
import {Button} from '@/components/ui/button';
|
import {Button} from '@/components/ui/button';
|
||||||
import {Card} from '@/components/ui/card';
|
import {Card} from '@/components/ui/card';
|
||||||
|
import {cn} from '@/lib/utils';
|
||||||
import GameCover from './GameCover.jsx';
|
import GameCover from './GameCover.jsx';
|
||||||
import GameStatusBadge from './GameStatusBadge.jsx';
|
import GameStatusBadge from './GameStatusBadge.jsx';
|
||||||
import ProfileArtMenu from './ProfileArtMenu.jsx';
|
import ProfileArtMenu from './ProfileArtMenu.jsx';
|
||||||
@@ -16,12 +17,15 @@ import {formatPlaytime} from '../utils/games.js';
|
|||||||
import {setGameHidden} from '../utils/hiddenGames.js';
|
import {setGameHidden} from '../utils/hiddenGames.js';
|
||||||
import {isSteamKey} from '../utils/manualGames.js';
|
import {isSteamKey} from '../utils/manualGames.js';
|
||||||
import {platformLabel} from '../utils/platforms.js';
|
import {platformLabel} from '../utils/platforms.js';
|
||||||
|
import {coverEffect, detailEffect} from '../utils/rewards.js';
|
||||||
|
|
||||||
const SPEEDRUN_PREFIX = 'https://www.speedrun.com/';
|
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}) {
|
export default function GameHeroCard({game, manual, auditor, profileStyle, onCorrect, onMerge, onHiddenChanged, onProfileStyleSaved}) {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const {appearance} = usePreferences();
|
const {appearance, equipped} = usePreferences();
|
||||||
const saveAppearance = useSaveAppearance();
|
const saveAppearance = useSaveAppearance();
|
||||||
const [hiding, setHiding] = useState(false);
|
const [hiding, setHiding] = useState(false);
|
||||||
|
|
||||||
@@ -31,6 +35,7 @@ export default function GameHeroCard({game, manual, auditor, profileStyle, onCor
|
|||||||
? game.speedrunUrl : null;
|
? game.speedrunUrl : null;
|
||||||
const isBackground = appearance.backgroundAppid === game.appid;
|
const isBackground = appearance.backgroundAppid === game.appid;
|
||||||
const isBanner = appearance.showBanner && appearance.bannerAppid === game.appid;
|
const isBanner = appearance.showBanner && appearance.bannerAppid === game.appid;
|
||||||
|
const detailFx = detailEffect(equipped, game.status);
|
||||||
|
|
||||||
const toggleHidden = async () => {
|
const toggleHidden = async () => {
|
||||||
const hidden = !game.hidden;
|
const hidden = !game.hidden;
|
||||||
@@ -54,8 +59,14 @@ export default function GameHeroCard({game, manual, auditor, profileStyle, onCor
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="relative overflow-hidden p-0 gap-0 md:flex-row">
|
<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"/>
|
{detailFx && (
|
||||||
<div className="flex flex-1 flex-col justify-between gap-4 p-6">
|
<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>
|
<div>
|
||||||
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">
|
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">
|
||||||
{game.name ?? `App ${game.appid}`}
|
{game.name ?? `App ${game.appid}`}
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ describe('PageBackground', () => {
|
|||||||
expect(container.innerHTML).toBe('');
|
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', () => {
|
it('starts with the library art again for another game', () => {
|
||||||
const {container, rerender} = render(<PageBackground appid={620}/>);
|
const {container, rerender} = render(<PageBackground appid={620}/>);
|
||||||
fireEvent.error(container.querySelector('img'));
|
fireEvent.error(container.querySelector('img'));
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import {cn} from '@/lib/utils';
|
import {cn} from '@/lib/utils';
|
||||||
|
import EffectBackground from './EffectBackground.jsx';
|
||||||
import HeroArt from './HeroArt.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
|
* 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.
|
* `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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<HeroArt key={appid} appid={appid} className="pointer-events-none absolute inset-0 size-full object-cover opacity-60"/>
|
{effect
|
||||||
<div className={cn('pointer-events-none absolute inset-0', appid
|
? <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-r from-card via-card/85 to-card/60'
|
||||||
: 'bg-gradient-to-br from-primary/15 via-transparent to-transparent')}/>
|
: 'bg-gradient-to-br from-primary/15 via-transparent to-transparent')}/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ import {Button} from '@/components/ui/button';
|
|||||||
import {Card} from '@/components/ui/card';
|
import {Card} from '@/components/ui/card';
|
||||||
import GameCover from './GameCover.jsx';
|
import GameCover from './GameCover.jsx';
|
||||||
import {formatPlaytime} from '../utils/games.js';
|
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 {t} = useTranslation();
|
||||||
const maxMinutes = games[0]?.playtimeMinutes || 1;
|
const maxMinutes = games[0]?.playtimeMinutes || 1;
|
||||||
return (
|
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">
|
<span className="w-4 text-right text-sm font-semibold text-muted-foreground tabular-nums">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</span>
|
</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="min-w-0 flex-1">
|
||||||
<div className="truncate font-medium group-hover:text-primary transition-colors">
|
<div className="truncate font-medium group-hover:text-primary transition-colors">
|
||||||
{g.name ?? `App ${g.appid}`}
|
{g.name ?? `App ${g.appid}`}
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
|
import EffectBackground from './EffectBackground.jsx';
|
||||||
import HeroArt from './HeroArt.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;
|
if (!appid) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {usePreferences} from '../context/PreferencesContext.jsx';
|
|||||||
import usePersistSettings from '../hooks/usePersistSettings.js';
|
import usePersistSettings from '../hooks/usePersistSettings.js';
|
||||||
import useSaveResponse from '../hooks/useSaveResponse.js';
|
import useSaveResponse from '../hooks/useSaveResponse.js';
|
||||||
import {FAVORITE_PLATFORMS, saveFavoritePlatform} from '../utils/platforms.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.
|
// Radix Select has no empty value.
|
||||||
const NO_FAVORITE = 'NONE';
|
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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -83,6 +90,14 @@ export default function PreferencesCard() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import {hasSession} from '../utils/auth.js';
|
import {hasSession} from '../utils/auth.js';
|
||||||
import useSession from '../hooks/useSession.js';
|
import useSession from '../hooks/useSession.js';
|
||||||
import {LANGUAGES, saveSettings} from '../utils/settings.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';
|
import {storage} from '../utils/storage.js';
|
||||||
|
|
||||||
const DARK_MODE_STORAGE_KEY = 'tbp_dark_mode';
|
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.
|
// Pile Points: balance, shop and worn looks of Core; null until loaded.
|
||||||
const [wallet, setWallet] = useState(null);
|
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.
|
// Layout effect: the cached look is set before the first paint.
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
applyAppearance(appearance, equippedAccent(wallet?.equipped));
|
applyAppearance(appearance, equippedAccent(equipped));
|
||||||
cacheAppearance(appearance);
|
cacheAppearance(appearance);
|
||||||
}, [appearance, wallet]);
|
cacheEquipped(equipped);
|
||||||
|
}, [appearance, equipped]);
|
||||||
|
|
||||||
const reloadWallet = useCallback(async () => {
|
const reloadWallet = useCallback(async () => {
|
||||||
const started = generation.current;
|
const started = generation.current;
|
||||||
const {ok, data} = await loadWallet();
|
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.
|
// 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;
|
savedAppearance.current = DEFAULT_APPEARANCE;
|
||||||
setAppearanceState(DEFAULT_APPEARANCE);
|
setAppearanceState(DEFAULT_APPEARANCE);
|
||||||
setWallet(null);
|
setWallet(null);
|
||||||
|
setEquipped({});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Without a session the next user must not get this look.
|
// Without a session the next user must not get this look.
|
||||||
@@ -166,6 +173,7 @@ export function PreferencesProvider({children}) {
|
|||||||
appearance,
|
appearance,
|
||||||
changeAppearance,
|
changeAppearance,
|
||||||
wallet,
|
wallet,
|
||||||
|
equipped,
|
||||||
reloadWallet,
|
reloadWallet,
|
||||||
syncPreferences,
|
syncPreferences,
|
||||||
resetAppearance,
|
resetAppearance,
|
||||||
|
|||||||
@@ -109,3 +109,32 @@ describe('PreferencesProvider theme color', () => {
|
|||||||
expect(themeColor()).toBe('#09090b');
|
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
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -54,7 +54,7 @@ describe('useSaveAppearance', () => {
|
|||||||
const [url, request] = fetch.mock.calls[0];
|
const [url, request] = fetch.mock.calls[0];
|
||||||
expect(url).toContain('/api/users/settings/appearance');
|
expect(url).toContain('/api/users/settings/appearance');
|
||||||
expect(request.method).toBe('PUT');
|
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 () => {
|
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 () => {
|
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;
|
let answerMe;
|
||||||
fetchRoutes({
|
fetchRoutes({
|
||||||
[PUT]: json(204),
|
[PUT]: json(204),
|
||||||
@@ -206,7 +206,7 @@ describe('useSaveAppearance', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.preferences.appearance.corners).toBe('round');
|
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 () => {
|
it('sends nothing for a change that waited for the load when a reset came first', async () => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -60,7 +60,7 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
const hasSteamId = !!userData?.steamId;
|
const hasSteamId = !!userData?.steamId;
|
||||||
const showLibrary = hasSteamId || steamData.count > 0;
|
const showLibrary = hasSteamId || steamData.count > 0;
|
||||||
const {appearance, wallet} = usePreferences();
|
const {appearance, equipped} = usePreferences();
|
||||||
const steamProfile = useSteamProfile(hasSteamId);
|
const steamProfile = useSteamProfile(hasSteamId);
|
||||||
const bannerAppid = appearance.showBanner
|
const bannerAppid = appearance.showBanner
|
||||||
? appearance.bannerAppid ?? activity?.recentGames?.[0]?.appid ?? null
|
? appearance.bannerAppid ?? activity?.recentGames?.[0]?.appid ?? null
|
||||||
@@ -99,7 +99,8 @@ const Dashboard = () => {
|
|||||||
switch (id) {
|
switch (id) {
|
||||||
case 'GAME_COUNT': return showLibrary && <SteamGameCount targetCount={steamData.count}/>;
|
case 'GAME_COUNT': return showLibrary && <SteamGameCount targetCount={steamData.count}/>;
|
||||||
case 'MOST_PLAYED':
|
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':
|
case 'PLAYING':
|
||||||
return showLibrary && (
|
return showLibrary && (
|
||||||
<CurrentlyPlayingCard games={playing.slice(0, PLAYING_GAMES[size])} syncing={sync.isRunning} wide={size === 'L'}/>
|
<CurrentlyPlayingCard games={playing.slice(0, PLAYING_GAMES[size])} syncing={sync.isRunning} wide={size === 'L'}/>
|
||||||
@@ -127,7 +128,7 @@ const Dashboard = () => {
|
|||||||
return (
|
return (
|
||||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||||
<DashboardWelcomeCard bannerAppid={bannerAppid} avatarUrl={steamProfile?.avatarUrl}
|
<DashboardWelcomeCard bannerAppid={bannerAppid} avatarUrl={steamProfile?.avatarUrl}
|
||||||
equipped={wallet?.equipped}
|
equipped={equipped}
|
||||||
sync={sync} forceSync={forceSync} editing={editing} onCustomize={() => setEditing(true)}/>
|
sync={sync} forceSync={forceSync} editing={editing} onCustomize={() => setEditing(true)}/>
|
||||||
|
|
||||||
<TileGrid tiles={DASHBOARD_TILES} titles={DASHBOARD_TITLES} layout={layout} renderTile={renderTile} editing={editing}
|
<TileGrid tiles={DASHBOARD_TILES} titles={DASHBOARD_TITLES} layout={layout} renderTile={renderTile} editing={editing}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,6 +20,8 @@ import GameCover from '../components/GameCover.jsx';
|
|||||||
import LibraryTabs from '../components/LibraryTabs.jsx';
|
import LibraryTabs from '../components/LibraryTabs.jsx';
|
||||||
import GameStatusBadge from '../components/GameStatusBadge.jsx';
|
import GameStatusBadge from '../components/GameStatusBadge.jsx';
|
||||||
import PageContainer from '../components/PageContainer.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 {apiFetch, request} from '../utils/api.js';
|
||||||
import useAbortPrevious from '../hooks/useAbortPrevious.js';
|
import useAbortPrevious from '../hooks/useAbortPrevious.js';
|
||||||
import {platformLabel} from '../utils/platforms.js';
|
import {platformLabel} from '../utils/platforms.js';
|
||||||
@@ -41,6 +43,7 @@ const SORT_OPTIONS = [
|
|||||||
|
|
||||||
export default function GameLibrary() {
|
export default function GameLibrary() {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
|
const {equipped} = usePreferences();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
const page = Math.max(0, parseInt(searchParams.get('page') ?? '0', 10) || 0);
|
const page = Math.max(0, parseInt(searchParams.get('page') ?? '0', 10) || 0);
|
||||||
@@ -286,7 +289,8 @@ export default function GameLibrary() {
|
|||||||
to={`/library/${g.appid}`}
|
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"
|
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">
|
<span className="min-w-0 flex-1 truncate font-medium group-hover:text-primary transition-colors">
|
||||||
{g.name ?? `App ${g.appid}`}
|
{g.name ?? `App ${g.appid}`}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import {useRef} from 'react';
|
||||||
import {Link} from 'react-router-dom';
|
import {Link} from 'react-router-dom';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {Coins} from 'lucide-react';
|
import {Coins} from 'lucide-react';
|
||||||
@@ -8,8 +9,10 @@ import {Button} from '@/components/ui/button';
|
|||||||
import {Card} from '@/components/ui/card';
|
import {Card} from '@/components/ui/card';
|
||||||
import {Skeleton} from '@/components/ui/skeleton';
|
import {Skeleton} from '@/components/ui/skeleton';
|
||||||
import {cn} from '@/lib/utils';
|
import {cn} from '@/lib/utils';
|
||||||
|
import EffectBackground from '../components/EffectBackground.jsx';
|
||||||
import PageContainer from '../components/PageContainer.jsx';
|
import PageContainer from '../components/PageContainer.jsx';
|
||||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||||
|
import useInView from '../hooks/useInView.js';
|
||||||
import {formatDate} from '../utils/format.js';
|
import {formatDate} from '../utils/format.js';
|
||||||
import {buyItem, equipItem, LOOKS, SLOTS} from '../utils/rewards.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}) {
|
function Preview({code}) {
|
||||||
const look = LOOKS[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) return null;
|
||||||
if (look.Icon) {
|
if (look.Icon) {
|
||||||
return <span className={cn('flex size-8 items-center justify-center rounded-full', look.className)}><look.Icon className="size-4"/></span>;
|
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) {
|
if (look.accent) {
|
||||||
return <span data-accent={look.accent} className="size-8 rounded-full bg-primary"/>;
|
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')}/>;
|
return <span className={cn('size-8 shrink-0 bg-muted', look.className, code.startsWith('BORDER') ? 'rounded-full' : 'rounded-md')}/>;
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
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 {toast} from 'sonner';
|
||||||
import PointsPage from './PointsPage.jsx';
|
import PointsPage from './PointsPage.jsx';
|
||||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
@@ -16,6 +16,9 @@ const wallet = (extra = {}) => ({
|
|||||||
{code: 'BORDER_GOLD', slot: 'AVATAR_BORDER', price: 150},
|
{code: 'BORDER_GOLD', slot: 'AVATAR_BORDER', price: 150},
|
||||||
{code: 'BORDER_NEON', slot: 'AVATAR_BORDER', price: 200},
|
{code: 'BORDER_NEON', slot: 'AVATAR_BORDER', price: 200},
|
||||||
{code: 'BADGE_PILE_SLAYER', slot: 'BADGE', price: 100},
|
{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'],
|
owned: ['BADGE_PILE_SLAYER'],
|
||||||
equipped: {},
|
equipped: {},
|
||||||
@@ -47,6 +50,42 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('PointsPage', () => {
|
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 () => {
|
it('groups the catalog by slot and disables Buy below the price', async () => {
|
||||||
await renderPage();
|
await renderPage();
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {cn} from '@/lib/utils';
|
|||||||
import GameCover from '../components/GameCover.jsx';
|
import GameCover from '../components/GameCover.jsx';
|
||||||
import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx';
|
import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx';
|
||||||
import {formatDay} from '../utils/format.js';
|
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 PointsBadge from '../components/PointsBadge.jsx';
|
||||||
import {GAME_STATUSES} from '../utils/limits.js';
|
import {GAME_STATUSES} from '../utils/limits.js';
|
||||||
import {formatPlaytime} from '../utils/games.js';
|
import {formatPlaytime} from '../utils/games.js';
|
||||||
@@ -135,7 +135,7 @@ export default function PublicProfilePage() {
|
|||||||
return library.playing?.length > 0
|
return library.playing?.length > 0
|
||||||
&& <Playing games={library.playing.slice(0, PLAYING_GAMES[size])} compact={size === 'S'}/>;
|
&& <Playing games={library.playing.slice(0, PLAYING_GAMES[size])} compact={size === 'S'}/>;
|
||||||
case 'FINISHED':
|
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);
|
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.
|
// The owner's accent and corners apply to this page only; light or dark mode stays the viewer's.
|
||||||
return (
|
return (
|
||||||
<div data-accent={equippedAccent(equipped) ?? style.accent} data-corners={style.corners}>
|
<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">
|
<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'))}>
|
<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">
|
<div className="relative">
|
||||||
<p className="text-sm font-medium text-muted-foreground">{t('PROFILE.TITLE')}</p>
|
<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>
|
<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();
|
const {t} = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Card className="gap-0 py-0">
|
<Card className="gap-0 py-0">
|
||||||
@@ -257,7 +257,8 @@ function Finished({games}) {
|
|||||||
<ul className="divide-y">
|
<ul className="divide-y">
|
||||||
{games.map((g) => (
|
{games.map((g) => (
|
||||||
<li key={g.appid} className="flex items-center gap-3 sm:gap-4 px-4 sm:px-5 py-3">
|
<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>
|
<span className="min-w-0 flex-1 truncate font-medium">{g.name ?? `App ${g.appid}`}</span>
|
||||||
{g.rating && (
|
{g.rating && (
|
||||||
<span className="flex items-center gap-0.5" aria-label={t('BACKLOG.RATING_STARS', {count: g.rating})}>
|
<span className="flex items-center gap-0.5" aria-label={t('BACKLOG.RATING_STARS', {count: g.rating})}>
|
||||||
|
|||||||
@@ -44,6 +44,17 @@ describe('PublicProfilePage', () => {
|
|||||||
expect(fetch.mock.calls[2][1].headers['X-Profile-Ticket']).toBe('the-ticket');
|
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 () => {
|
it('still shows the profile when the looks call fails', async () => {
|
||||||
mockBackends({style: {accent: 'red'}, rewardsStatus: 500});
|
mockBackends({style: {accent: 'red'}, rewardsStatus: 500});
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const authRoutes = (me) => ({
|
|||||||
'PUT /api/users/settings/appearance': noBody,
|
'PUT /api/users/settings/appearance': noBody,
|
||||||
'PUT /api/users/settings/profile-style': noBody,
|
'PUT /api/users/settings/profile-style': noBody,
|
||||||
'PUT /api/users/settings/favorite-platform': noBody,
|
'PUT /api/users/settings/favorite-platform': noBody,
|
||||||
|
'PUT /api/users/settings/platform-mails': noBody,
|
||||||
'GET /api/version': json(404, {}),
|
'GET /api/version': json(404, {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -186,11 +187,20 @@ describe('SettingsPage appearance', () => {
|
|||||||
fireEvent.click(appearanceCard().getByRole('button', {name: 'SETTINGS.CORNER_OPTIONS.ROUND'}));
|
fireEvent.click(appearanceCard().getByRole('button', {name: 'SETTINGS.CORNER_OPTIONS.ROUND'}));
|
||||||
|
|
||||||
await waitFor(() => expect(appearancePuts()).toHaveLength(2));
|
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.GREEN'}).getAttribute('aria-pressed')).toBe('true');
|
||||||
expect(appearanceCard().getByRole('button', {name: 'SETTINGS.ACCENT_OPTIONS.VIOLET'}).getAttribute('aria-pressed')).toBe('false');
|
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 () => {
|
it('removes the background', async () => {
|
||||||
localStorage.setItem('tbp_appearance', JSON.stringify({backgroundAppid: 620}));
|
localStorage.setItem('tbp_appearance', JSON.stringify({backgroundAppid: 620}));
|
||||||
renderPage(profile());
|
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', () => {
|
describe('SettingsPage session', () => {
|
||||||
beforeEach(() => localStorage.setItem('tbp_session', '1'));
|
beforeEach(() => localStorage.setItem('tbp_session', '1'));
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
|
@import "./effects.css";
|
||||||
@import "@fontsource-variable/inter";
|
@import "@fontsource-variable/inter";
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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.
|
// bannerAppid null: the dashboard banner shows the game played most in the last two weeks.
|
||||||
export const DEFAULT_APPEARANCE = {
|
export const DEFAULT_APPEARANCE = {
|
||||||
accent: 'violet', corners: 'default', wideLayout: false, backgroundAppid: null, bannerAppid: null, showBanner: true,
|
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);
|
export const appid = (value) => (Number.isInteger(value) && value > 0 ? value : null);
|
||||||
@@ -22,6 +23,7 @@ export const normalizeAppearance = (value) => ({
|
|||||||
backgroundAppid: appid(value?.backgroundAppid),
|
backgroundAppid: appid(value?.backgroundAppid),
|
||||||
bannerAppid: appid(value?.bannerAppid),
|
bannerAppid: appid(value?.bannerAppid),
|
||||||
showBanner: value?.showBanner !== false,
|
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) => {
|
export const applyAppearance = (appearance, accentOverride = null, root = document.documentElement) => {
|
||||||
root.dataset.accent = accentOverride ?? appearance.accent;
|
root.dataset.accent = accentOverride ?? appearance.accent;
|
||||||
root.dataset.corners = appearance.corners;
|
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.
|
* PUT /api/users/settings/appearance on Auth. The caller handles the response status.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import {afterEach, describe, expect, it} from 'vitest';
|
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||||
import {
|
import {
|
||||||
applyAppearance,
|
applyAppearance,
|
||||||
cacheAppearance,
|
cacheAppearance,
|
||||||
DEFAULT_APPEARANCE,
|
DEFAULT_APPEARANCE,
|
||||||
normalizeAppearance,
|
normalizeAppearance,
|
||||||
readCachedAppearance,
|
readCachedAppearance,
|
||||||
|
reduceMotion,
|
||||||
} from './appearance.js';
|
} from './appearance.js';
|
||||||
|
|
||||||
describe('normalizeAppearance', () => {
|
describe('normalizeAppearance', () => {
|
||||||
it('keeps known values', () => {
|
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);
|
expect(normalizeAppearance(appearance)).toEqual(appearance);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('replaces unknown or missing values with the defaults', () => {
|
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);
|
.toEqual(DEFAULT_APPEARANCE);
|
||||||
expect(normalizeAppearance(null)).toEqual(DEFAULT_APPEARANCE);
|
expect(normalizeAppearance(null)).toEqual(DEFAULT_APPEARANCE);
|
||||||
expect(normalizeAppearance({backgroundAppid: 1.5})).toEqual(DEFAULT_APPEARANCE);
|
expect(normalizeAppearance({backgroundAppid: 1.5})).toEqual(DEFAULT_APPEARANCE);
|
||||||
@@ -27,9 +28,9 @@ describe('appearance cache', () => {
|
|||||||
afterEach(() => localStorage.clear());
|
afterEach(() => localStorage.clear());
|
||||||
|
|
||||||
it('reads back what was cached', () => {
|
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', () => {
|
it('falls back to the defaults for a broken or empty cache', () => {
|
||||||
@@ -58,3 +59,41 @@ describe('applyAppearance', () => {
|
|||||||
expect(root.dataset.accent).toBe('gold');
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)});
|
||||||
@@ -33,3 +33,6 @@ export const MAX_HINT_LENGTH = 500;
|
|||||||
// Auth NewsRequest @Size(max = 100) and @Size(max = 2000)
|
// Auth NewsRequest @Size(max = 100) and @Size(max = 2000)
|
||||||
export const MAX_NEWS_TITLE_LENGTH = 100;
|
export const MAX_NEWS_TITLE_LENGTH = 100;
|
||||||
export const MAX_NEWS_TEXT_LENGTH = 2000;
|
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
@@ -1,10 +1,11 @@
|
|||||||
import {Swords, Trophy} from 'lucide-react';
|
import {Swords, Trophy} from 'lucide-react';
|
||||||
import {apiFetch, request} from './api.js';
|
import {apiFetch, request} from './api.js';
|
||||||
|
import {storage} from './storage.js';
|
||||||
|
|
||||||
const REWARDS_URL = `${import.meta.env.VITE_CORE_API_URL}/api/rewards`;
|
const REWARDS_URL = `${import.meta.env.VITE_CORE_API_URL}/api/rewards`;
|
||||||
const JSON_HEADERS = {'Content-Type': 'application/json'};
|
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.
|
// 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.
|
// An unknown code is skipped.
|
||||||
@@ -17,6 +18,28 @@ export const LOOKS = {
|
|||||||
ACCENT_TEAL: {accent: 'teal'},
|
ACCENT_TEAL: {accent: 'teal'},
|
||||||
FRAME_GOLD: {className: 'ring-2 ring-amber-400 ring-offset-2 ring-offset-background'},
|
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'},
|
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). */
|
/** 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. */
|
/** The classes of the item worn in a slot, or undefined. */
|
||||||
export const lookClass = (equipped, slot) => LOOKS[equipped?.[slot]]?.className;
|
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. */
|
/** The badge worn: `{code, Icon, className}` or null. */
|
||||||
export const equippedBadge = (equipped) => {
|
export const equippedBadge = (equipped) => {
|
||||||
const look = LOOKS[equipped?.BADGE];
|
const look = LOOKS[equipped?.BADGE];
|
||||||
return look?.Icon ? {code: equipped.BADGE, ...look} : null;
|
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. */
|
/** 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`);
|
export const loadWallet = () => request(`${REWARDS_URL}/wallet`);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -58,3 +58,11 @@ export const markWelcomeSeen = () =>
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
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}),
|
||||||
|
});
|
||||||
Reference in new issue
Block a user