Compare commits

..
2 Commits
Author SHA1 Message Date
lobo b6f3d123fb feat(points): unlock items, progress bars and skins
Build, Test & Push Frontend / docker (push) Successful in 25s
Build, Test & Push Frontend / quality-check (push) Successful in 28s
Locked items show a progress bar until the goal is reached. Four
CSS-only skins (Pastel, Retro CRT, Cyberpunk, Steam) in skins.css,
light and dark. A skin overrides accent and corners; badges, borders,
frames, effects and game art stay on top. New Platinum shine and
Equalizer effects.
2026-10-02 15:40:46 +02:00
lobo 8598024bce feat(hint): targets, automatic hints and formatting
Target fields on the admin hints page. The dashboard shows the four
automatic hints after admin hints when their condition is true, using
data Core already serves. HintText renders **bold** and safe links (app
path or https) as React elements, never HTML, in hints and news.
Auto-close without tick hides for this session, X for 7 days, tick + X
for good.
2026-10-02 14:36:49 +02:00
33 changed files with 1197 additions and 88 deletions

No files matched your search

+11 -2
View File
@@ -34,7 +34,10 @@ To Be Played is split into three repositories:
- **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, a
banner frame, an effect for 100 % games and an animated page or banner background (CSS and SVG, in `effects.css`),
shown on the dashboard and the public profile. The balance is in the header. Every effect stands still with the
shown on the dashboard and the public profile. Twelve more items are unlock-only: they show locked with a progress bar
until a goal is reached (games finished, 100 % games, weeks in a row, months with a smaller pile, months with more
play time). Skins (`skins.css`, Pastel, Retro CRT, Cyberpunk bought, Steam unlocked) are full themes made of CSS
variables; a skin wins over accent and corners, while badges, borders, frames and effects stay on top. The balance is in the header. Every effect stands still with the
system setting "reduce motion" or the "Reduce animations" switch in the settings.
- **Landing page**: next to the community numbers and the top games, three blocks: games per platform, top genres and
finished games per year of all users.
@@ -118,7 +121,13 @@ To Be Played is split into three repositories:
and menu opening. A request whose address is not confirmed shows "Not confirmed" and only a Delete button. The login tells a waiting user that the admin has not decided yet.
- **Dashboard hints**: news from the admin at the bottom of the dashboard, one at a time. A "Dismiss" hint closes
after 30 s or with X and comes back in the next browser session unless "Do not show again" is ticked; an
"Accept" hint stays until "OK". Admins write, preview and end hints on `/admin/hints` (Auth stores them).
"Accept" hint stays until "OK". Admins write, preview and end hints on `/admin/hints` (Auth stores them). A hint can
target groups (without Steam, role and higher, new users, empty library; all must match). Four automatic hints
(no Steam, empty library, pile grew this month, open link queue for auditors) show after the admin hints while
their condition is true; the Frontend checks it with Core's library summary, stats and link queue. X hides an
automatic hint for 7 days, "Do not show again" for good, the 30 s auto close only for this browser session.
Admins turn each on or off on `/admin/hints`. Hint and news text may use `**bold**` and `[text](url)` (an app
path or https; anything else stays plain text); `HintText` renders React elements, never HTML.
- **What's new**: a dialog on the dashboard shows the news entries the user has not seen yet, newest first. "Got it"
marks them seen in Auth; closing the dialog any other way shows them again on the next dashboard visit. `/whats-new`
(menu) lists all entries and marks them seen. Admins publish, preview, edit and delete entries on `/admin/news`.
+61 -9
View File
@@ -168,7 +168,8 @@
"PROFILE_TILES_HINT": "Wähle, was dein Profil zeigt und wo: öffne dein Profil und klicke auf \"Anpassen\".",
"FAVORITE_PLATFORM": "Lieblingsplattform",
"FAVORITE_PLATFORM_HINT": "Wird zuerst gewählt, wenn du ein Spiel für mehrere Plattformen hinzufügst.",
"FAVORITE_PLATFORM_NONE": "Keine"
"FAVORITE_PLATFORM_NONE": "Keine",
"SKIN_OVERRIDES": "Dein Skin legt Farben und Ecken fest. Nimm ihn auf der Punkte-Seite ab."
},
"VALIDATION": {
"EMAIL_REQUIRED": "Email ist erforderlich.",
@@ -739,13 +740,19 @@
"CLOSE": "Schließen",
"DO_NOT_SHOW_AGAIN": "Nicht mehr anzeigen",
"OK": "OK",
"SAVE_FAILED": "Der Hinweis konnte nicht gespeichert werden."
"SAVE_FAILED": "Der Hinweis konnte nicht gespeichert werden.",
"AUTO_NO_STEAM": "[Verknüpfe dein Steam-Konto](/settings), damit deine Bibliothek abgeglichen wird.",
"AUTO_EMPTY_LIBRARY": "Deine Bibliothek ist noch leer. [Importiere deine Spiele](/import).",
"AUTO_PILE_GREW_one": "Dein Stapel ist diesen Monat um **{{count}}** Spiel gewachsen. [Zum Verlauf](/stats).",
"AUTO_PILE_GREW_other": "Dein Stapel ist diesen Monat um **{{count}}** Spiele gewachsen. [Zum Verlauf](/stats).",
"AUTO_LINK_QUEUE_one": "**{{count}}** Paar wartet in der [Verknüpfungs-Warteschlange](/audit/links).",
"AUTO_LINK_QUEUE_other": "**{{count}}** Paare warten in der [Verknüpfungs-Warteschlange](/audit/links)."
},
"ADMIN_HINTS": {
"NEW": "Neuer Hinweis",
"TEXT_EN": "Text (Englisch)",
"TEXT_DE": "Text (Deutsch, optional)",
"TEXT_HELP": "Nur Text, höchstens 500 Zeichen. Ohne deutschen Text sehen deutsche Nutzer den englischen.",
"TEXT_HELP": "Nur Text, höchstens 500 Zeichen. Mit **fett** und [Text](/pfad) oder [Text](https://...) für Links. Ohne deutschen Text sehen deutsche Nutzer den englischen.",
"KIND": "Art",
"KIND_DISMISS": "Schließen: geht nach 30 s oder mit X zu",
"KIND_ACCEPT": "Bestätigen: bleibt, bis der Nutzer OK klickt",
@@ -767,7 +774,26 @@
"STATUS_ENDED": "Beendet",
"RANGE": "{{start}} bis {{end}}",
"DONE_COUNT_one": "Von {{count}} Nutzer für immer geschlossen",
"DONE_COUNT_other": "Von {{count}} Nutzern für immer geschlossen"
"DONE_COUNT_other": "Von {{count}} Nutzern für immer geschlossen",
"TARGET": "Wer es sieht",
"TARGET_NO_STEAM": "Nur Nutzer ohne verknüpftes Steam-Konto",
"TARGET_EMPTY_LIBRARY": "Nur Nutzer mit leerer Bibliothek",
"TARGET_ROLE": "Rolle (diese Rolle und höher)",
"TARGET_ROLE_ALL": "Alle Rollen",
"TARGET_NEW_DAYS": "Nur Nutzer, die sich in den letzten N Tagen registriert haben (leer: egal)",
"TARGET_HELP": "Alle angehakten Gruppen müssen zutreffen. Nichts angehakt: alle Nutzer.",
"TARGET_ALL": "Alle Nutzer",
"TARGET_SUMMARY_NO_STEAM": "Ohne Steam",
"TARGET_SUMMARY_ROLE": "{{role}} und höher",
"TARGET_SUMMARY_NEW_one": "Registriert am letzten Tag",
"TARGET_SUMMARY_NEW_other": "Registriert in den letzten {{count}} Tagen",
"TARGET_SUMMARY_EMPTY_LIBRARY": "Leere Bibliothek",
"AUTO_TITLE": "Automatische Hinweise",
"AUTO_NO_STEAM": "Kein Steam-Konto verknüpft",
"AUTO_EMPTY_LIBRARY": "Leere Bibliothek",
"AUTO_PILE_GREW": "Der Stapel ist diesen Monat gewachsen",
"AUTO_LINK_QUEUE": "Offene Paare in der Verknüpfungs-Warteschlange (Auditoren)",
"AUTO_SAVED": "Gespeichert."
},
"NEWS": {
"TITLE": "Neuigkeiten",
@@ -806,7 +832,7 @@
"TITLE_DE": "Titel (Deutsch, optional)",
"TEXT_EN": "Text (Englisch)",
"TEXT_DE": "Text (Deutsch, optional)",
"TEXT_HELP": "Nur Text. Titel höchstens 100, Text höchstens 2000 Zeichen. Ohne deutschen Text sehen deutsche Nutzer den englischen.",
"TEXT_HELP": "Nur Text. Titel höchstens 100, Text höchstens 2000 Zeichen. Im Text geht **fett** und [Text](/pfad) oder [Text](https://...). Ohne deutschen Text sehen deutsche Nutzer den englischen.",
"PREVIEW": "Vorschau",
"CREATE": "Veröffentlichen",
"SAVE": "Speichern",
@@ -981,7 +1007,8 @@
"COMPLETED": "+40 Punkte, wenn du in einem abgeschlossenen Spiel 100 % der Erfolge hast.",
"FINISHED_MANUAL": "15 Punkte für ein manuelles Spiel, das du durchspielst, höchstens 3 pro Woche.",
"MONTH": "40 Punkte, wenn dein Stapel am Monatsende kleiner ist als am Monatsanfang und du in dem Monat ein Spiel durchgespielt hast.",
"STREAK": "20 Punkte für jede Woche mit einem beendeten Spiel nach einer Woche mit einem beendeten Spiel."
"STREAK": "20 Punkte für jede Woche mit einem beendeten Spiel nach einer Woche mit einem beendeten Spiel.",
"UNLOCK": "Gegenstände mit Schloss kann man nicht kaufen. Sie werden einmal und für immer freigeschaltet, wenn du ihr Ziel erreichst."
},
"SLOTS": {
"AVATAR_BORDER": "Avatarrahmen",
@@ -990,7 +1017,8 @@
"BANNER_FRAME": "Bannerrahmen",
"COMPLETED_EFFECT": "Effekt für 100 %",
"PAGE_BACKGROUND": "Seitenhintergrund",
"BANNER_BACKGROUND": "Bannerhintergrund"
"BANNER_BACKGROUND": "Bannerhintergrund",
"SKIN": "Skin"
},
"ITEMS": {
"BORDER_GOLD": "Goldener Avatarrahmen",
@@ -1020,7 +1048,22 @@
"BG_WAVES": "Animierter Hintergrund: Wellen",
"BN_WAVES": "Animiertes Banner: Wellen",
"BG_BOKEH": "Animierter Hintergrund: Bokeh-Lichter",
"BN_BOKEH": "Animiertes Banner: Bokeh-Lichter"
"BN_BOKEH": "Animiertes Banner: Bokeh-Lichter",
"BADGE_FINISH_10": "Beender-Abzeichen",
"BORDER_FINISH_25": "Silberner Avatar-Rand",
"FRAME_FINISH_50": "Smaragd-Bannerrahmen",
"BADGE_PERFECT_5": "Perfektionisten-Abzeichen",
"FX_PLATINUM": "Platin-Glanz",
"BADGE_STREAK_4": "Serien-Abzeichen",
"BORDER_STREAK_12": "Flammen-Avatar-Rand",
"FRAME_PILE_DOWN_3": "Türkiser Bannerrahmen",
"BADGE_PLAY_MORE_1": "Mehr-spielen-Abzeichen",
"BORDER_PLAY_MORE_3": "Himmelblauer Avatar-Rand",
"BN_EQUALIZER": "Animiertes Banner: Equalizer",
"SKIN_PASTEL": "Skin: Pastell",
"SKIN_RETRO_CRT": "Skin: Retro-Röhre",
"SKIN_CYBERPUNK": "Skin: Cyberpunk",
"SKIN_STEAM": "Skin: Steam"
},
"KINDS": {
"STARTED": "Gestartet",
@@ -1030,7 +1073,16 @@
"MONTH": "Kleinerer Stapel im",
"STREAK": "Serien-Woche",
"STARTER": "Startpunkte",
"BUY": "Gekauft"
"BUY": "Gekauft",
"UNLOCK": "Freigeschaltet"
},
"LOCKED": "Gesperrt, Fortschritt",
"PROGRESS": {
"GAMES_FINISHED": "{{count}} / {{target}} Spiele beendet",
"GAMES_COMPLETED": "{{count}} / {{target}} Spiele zu 100 %",
"FINISH_WEEKS": "{{count}} / {{target}} Wochen in Folge mit einem beendeten Spiel",
"PILE_DOWN_MONTHS": "{{count}} / {{target}} Monate in Folge mit kleinerem Stapel",
"PLAY_MORE_MONTHS": "{{count}} / {{target}} Monate in Folge mit mehr Spielzeit als im Monat davor"
}
},
"UNSAVED_CHANGES": {
+61 -9
View File
@@ -168,7 +168,8 @@
"PROFILE_TILES_HINT": "Choose what your profile shows, and where: open your profile and click \"Customize\".",
"FAVORITE_PLATFORM": "Favorite platform",
"FAVORITE_PLATFORM_HINT": "Selected first when you add a game on more than one platform.",
"FAVORITE_PLATFORM_NONE": "None"
"FAVORITE_PLATFORM_NONE": "None",
"SKIN_OVERRIDES": "Your skin sets colors and corners. Take it off on the Points page."
},
"VALIDATION": {
"EMAIL_REQUIRED": "Email is required.",
@@ -739,13 +740,19 @@
"CLOSE": "Close",
"DO_NOT_SHOW_AGAIN": "Do not show again",
"OK": "OK",
"SAVE_FAILED": "The hint could not be saved."
"SAVE_FAILED": "The hint could not be saved.",
"AUTO_NO_STEAM": "[Link your Steam account](/settings) to sync your library.",
"AUTO_EMPTY_LIBRARY": "Your library is still empty. [Import your games](/import).",
"AUTO_PILE_GREW_one": "Your pile grew by **{{count}}** game this month. [See the trend](/stats).",
"AUTO_PILE_GREW_other": "Your pile grew by **{{count}}** games this month. [See the trend](/stats).",
"AUTO_LINK_QUEUE_one": "**{{count}}** pair waits in the [link queue](/audit/links).",
"AUTO_LINK_QUEUE_other": "**{{count}}** pairs wait in the [link queue](/audit/links)."
},
"ADMIN_HINTS": {
"NEW": "New hint",
"TEXT_EN": "Text (English)",
"TEXT_DE": "Text (German, optional)",
"TEXT_HELP": "Plain text, max 500 characters. Without a German text, German users see the English one.",
"TEXT_HELP": "Plain text, max 500 characters. Use **bold** and [text](/path) or [text](https://...) for links. Without a German text, German users see the English one.",
"KIND": "Type",
"KIND_DISMISS": "Dismiss: closes after 30 s or with X",
"KIND_ACCEPT": "Accept: stays until the user clicks OK",
@@ -767,7 +774,26 @@
"STATUS_ENDED": "Ended",
"RANGE": "{{start}} to {{end}}",
"DONE_COUNT_one": "Closed for good by {{count}} user",
"DONE_COUNT_other": "Closed for good by {{count}} users"
"DONE_COUNT_other": "Closed for good by {{count}} users",
"TARGET": "Who sees it",
"TARGET_NO_STEAM": "Only users without a linked Steam account",
"TARGET_EMPTY_LIBRARY": "Only users with an empty library",
"TARGET_ROLE": "Role (this role and higher)",
"TARGET_ROLE_ALL": "All roles",
"TARGET_NEW_DAYS": "Only users registered in the last N days (empty: any)",
"TARGET_HELP": "All ticked groups must match. Nothing ticked: all users.",
"TARGET_ALL": "All users",
"TARGET_SUMMARY_NO_STEAM": "Without Steam",
"TARGET_SUMMARY_ROLE": "{{role}} and higher",
"TARGET_SUMMARY_NEW_one": "Registered in the last day",
"TARGET_SUMMARY_NEW_other": "Registered in the last {{count}} days",
"TARGET_SUMMARY_EMPTY_LIBRARY": "Empty library",
"AUTO_TITLE": "Automatic hints",
"AUTO_NO_STEAM": "No Steam account linked",
"AUTO_EMPTY_LIBRARY": "Empty library",
"AUTO_PILE_GREW": "The pile grew this month",
"AUTO_LINK_QUEUE": "Open pairs in the link queue (auditors)",
"AUTO_SAVED": "Saved."
},
"NEWS": {
"TITLE": "What's new",
@@ -806,7 +832,7 @@
"TITLE_DE": "Title (German, optional)",
"TEXT_EN": "Text (English)",
"TEXT_DE": "Text (German, optional)",
"TEXT_HELP": "Plain text. Title max 100, text max 2000 characters. Without a German text, German users see the English one.",
"TEXT_HELP": "Plain text. Title max 100, text max 2000 characters. Use **bold** and [text](/path) or [text](https://...) in the text. Without a German text, German users see the English one.",
"PREVIEW": "Preview",
"CREATE": "Publish",
"SAVE": "Save",
@@ -981,7 +1007,8 @@
"COMPLETED": "+40 points when you reach 100 % achievements in a completed game.",
"FINISHED_MANUAL": "15 points for a manual game you finish, at most 3 per week.",
"MONTH": "40 points when your pile is smaller at the end of a month than at its start and you finished a game that month.",
"STREAK": "20 points for every week with a finished game after a week with a finished game."
"STREAK": "20 points for every week with a finished game after a week with a finished game.",
"UNLOCK": "Items with a lock cannot be bought. They unlock once, for good, when you reach their goal."
},
"SLOTS": {
"AVATAR_BORDER": "Avatar border",
@@ -990,7 +1017,8 @@
"BANNER_FRAME": "Banner frame",
"COMPLETED_EFFECT": "Completed effect",
"PAGE_BACKGROUND": "Page background",
"BANNER_BACKGROUND": "Banner background"
"BANNER_BACKGROUND": "Banner background",
"SKIN": "Skin"
},
"ITEMS": {
"BORDER_GOLD": "Golden avatar border",
@@ -1020,7 +1048,22 @@
"BG_WAVES": "Animated background: waves",
"BN_WAVES": "Animated banner: waves",
"BG_BOKEH": "Animated background: bokeh",
"BN_BOKEH": "Animated banner: bokeh"
"BN_BOKEH": "Animated banner: bokeh",
"BADGE_FINISH_10": "Finisher badge",
"BORDER_FINISH_25": "Silver avatar border",
"FRAME_FINISH_50": "Emerald banner frame",
"BADGE_PERFECT_5": "Perfectionist badge",
"FX_PLATINUM": "Platinum shine",
"BADGE_STREAK_4": "Streak badge",
"BORDER_STREAK_12": "Flame avatar border",
"FRAME_PILE_DOWN_3": "Teal banner frame",
"BADGE_PLAY_MORE_1": "Play more badge",
"BORDER_PLAY_MORE_3": "Sky avatar border",
"BN_EQUALIZER": "Animated banner: equalizer",
"SKIN_PASTEL": "Skin: Pastel",
"SKIN_RETRO_CRT": "Skin: Retro CRT",
"SKIN_CYBERPUNK": "Skin: Cyberpunk",
"SKIN_STEAM": "Skin: Steam"
},
"KINDS": {
"STARTED": "Started",
@@ -1030,7 +1073,16 @@
"MONTH": "Smaller pile in",
"STREAK": "Streak week",
"STARTER": "Starter points",
"BUY": "Bought"
"BUY": "Bought",
"UNLOCK": "Unlocked"
},
"LOCKED": "Locked, progress",
"PROGRESS": {
"GAMES_FINISHED": "{{count}} / {{target}} games finished",
"GAMES_COMPLETED": "{{count}} / {{target}} games at 100 %",
"FINISH_WEEKS": "{{count}} / {{target}} weeks in a row with a finish",
"PILE_DOWN_MONTHS": "{{count}} / {{target}} months in a row with a smaller pile",
"PLAY_MORE_MONTHS": "{{count}} / {{target}} months in a row with more play time than the month before"
}
},
"UNSAVED_CHANGES": {
+1 -1
View File
@@ -64,7 +64,7 @@ function App() {
return (
<TooltipProvider>
{/* 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 skin-bg text-foreground font-sans">
{/* A public profile shows the owner's background instead. */}
{isAuthenticated && !location.pathname.startsWith('/u/') && (
<PageBackground appid={appearance.backgroundAppid} effect={backgroundEffect(equipped)}/>
+3 -1
View File
@@ -6,13 +6,14 @@ import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import {Label} from '@/components/ui/label';
import {Switch} from '@/components/ui/switch';
import {AccentPicker, CornersPicker} from './StylePickers.jsx';
import {equippedSkin} from '../utils/rewards.js';
import {usePreferences} from '../context/PreferencesContext.jsx';
import useSaveAppearance from '../hooks/useSaveAppearance.js';
import {coverUrl} from '../utils/games.js';
export default function AppearanceCard() {
const {t} = useTranslation();
const {appearance} = usePreferences();
const {appearance, equipped} = usePreferences();
const saveAppearance = useSaveAppearance();
return (
@@ -23,6 +24,7 @@ export default function AppearanceCard() {
<CardContent className="space-y-6">
<AccentPicker id="accent" value={appearance.accent} onChange={(accent) => saveAppearance({accent})}/>
<CornersPicker id="corners" value={appearance.corners} onChange={(corners) => saveAppearance({corners})}/>
{equippedSkin(equipped) && <p className="text-xs text-muted-foreground">{t('SETTINGS.SKIN_OVERRIDES')}</p>}
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<Label htmlFor="wideLayout">{t('SETTINGS.WIDE_LAYOUT')}</Label>
+31
View File
@@ -0,0 +1,31 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen} from '@testing-library/react';
import AppearanceCard from './AppearanceCard.jsx';
const preferences = vi.hoisted(() => ({value: null}));
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('../context/PreferencesContext.jsx', () => ({usePreferences: () => preferences.value}));
vi.mock('../hooks/useSaveAppearance.js', () => ({default: () => vi.fn()}));
const show = (equipped) => {
preferences.value = {
appearance: {accent: 'violet', corners: 'default', wideLayout: false, reduceMotion: false, showBanner: true},
equipped,
};
render(<AppearanceCard/>);
};
afterEach(cleanup);
describe('AppearanceCard', () => {
it('tells that the skin sets colors and corners only while a skin is worn', () => {
show({SKIN: 'SKIN_STEAM'});
expect(screen.getByText('SETTINGS.SKIN_OVERRIDES')).toBeTruthy();
});
it('shows no skin hint without a skin', () => {
show({});
expect(screen.queryByText('SETTINGS.SKIN_OVERRIDES')).toBeNull();
});
});
+61 -18
View File
@@ -1,9 +1,11 @@
import {useCallback, useEffect, useState} from 'react';
import {useCallback, useEffect, useMemo, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
import HintCard from './HintCard.jsx';
import {fetchHints, markHintDone} from '../utils/hints.js';
import {AUTO_HINTS, fetchHints, hideAutoHint, markHintDone, pileGrowth, snoozeAutoHint} from '../utils/hints.js';
import {canCorrect} from '../utils/corrections.js';
import {fetchLinkQueue} from '../utils/links.js';
export const AUTO_CLOSE_MS = 30_000;
const CLOSED_KEY = 'tbp_closed_hints';
@@ -26,34 +28,75 @@ const rememberClosed = (hintId) => {
};
/**
* The admin hints on the dashboard, one at a time, newest first. A DISMISS hint closed without "Do not show again"
* comes back in the next browser session.
* The hints on the dashboard, one at a time: the admin hints newest first, then the automatic ones. A DISMISS hint
* closed by the 30 s timer without "Do not show again" comes back in the next browser session. The X on an automatic
* hint hides it for 7 days, "Do not show again" for good.
*
* @param user the current user
* @param summary Core's library summary; undefined while loading, null when it failed
* @param trend the TREND group of the stats; undefined while loading, null when it failed
*/
export default function DashboardHint() {
export default function DashboardHint({user, summary, trend}) {
const {t} = useTranslation();
const [hints, setHints] = useState([]);
const [data, setData] = useState({hints: [], automatic: []});
const [closed, setClosed] = useState(closedIds);
const [hideForever, setHideForever] = useState(false);
const hint = hints[0];
const [linkCount, setLinkCount] = useState();
const wantsLinkQueue = data.automatic.includes('LINK_QUEUE') && canCorrect(user);
useEffect(() => {
// A failed load shows nothing, not an error.
fetchHints()
.then((response) => (response.ok ? response.json() : null))
.then((data) => {
const closed = closedIds();
setHints((data?.results ?? []).filter((hint) => !closed.includes(hint.id)));
})
.then((body) => setData({hints: body?.results ?? [], automatic: body?.automatic ?? []}))
.catch(() => undefined);
}, []);
useEffect(() => {
if (!wantsLinkQueue) return;
// The queue is an AUDITOR page: a failure or 403 counts as an empty queue.
fetchLinkQueue({page: 0, size: 20})
.then((response) => (response.ok ? response.json() : null))
.then((body) => setLinkCount(body?.totalElements ?? 0))
.catch(() => setLinkCount(0));
}, [wantsLinkQueue]);
const hint = useMemo(() => {
const {hints, automatic} = data;
const links = wantsLinkQueue ? linkCount : 0;
const needsSummary = hints.some((h) => h.emptyLibraryOnly) || automatic.includes('EMPTY_LIBRARY');
// Show nothing until every fact the cards need has settled, so a later fact does not swap the card.
if ((needsSummary && summary === undefined) || (automatic.includes('PILE_GREW') && trend === undefined)
|| (automatic.includes('NO_STEAM') && !user) || links === undefined) {
return undefined;
}
const libraryEmpty = summary?.total === 0;
const growth = pileGrowth(trend);
const condition = {NO_STEAM: !user?.steamId, EMPTY_LIBRARY: libraryEmpty, LINK_QUEUE: links > 0, PILE_GREW: growth > 0};
const count = {LINK_QUEUE: links, PILE_GREW: growth};
return [
...hints.filter((h) => !h.emptyLibraryOnly || libraryEmpty),
...AUTO_HINTS.filter((key) => automatic.includes(key) && condition[key]).map((key) => ({
id: `auto:${key}`, auto: key, kind: 'DISMISS', text: t(`HINT.AUTO_${key}`, {count: count[key]}),
})),
].find((h) => !closed.includes(h.id));
}, [data, closed, summary, trend, user, linkCount, wantsLinkQueue, t]);
// A failed save only shows an error: the hint comes back in the next browser session.
const close = useCallback(async (done) => {
const close = useCallback(async (forever, byUser) => {
rememberClosed(hint.id);
setHints((current) => current.slice(1));
setClosed((current) => [...current, hint.id]);
setHideForever(false);
if (!done) return;
let save = null;
if (hint.auto) {
if (forever) save = () => hideAutoHint(hint.auto);
else if (byUser) save = () => snoozeAutoHint(hint.auto);
} else if (forever) {
save = () => markHintDone(hint.id);
}
if (!save) return;
try {
const response = await markHintDone(hint.id);
const response = await save();
if (!response.ok) toast.error(t('HINT.SAVE_FAILED'));
} catch {
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
@@ -62,7 +105,7 @@ export default function DashboardHint() {
useEffect(() => {
if (hint?.kind !== 'DISMISS') return undefined;
const timer = setTimeout(() => close(hideForever), AUTO_CLOSE_MS);
const timer = setTimeout(() => close(hideForever, false), AUTO_CLOSE_MS);
return () => clearTimeout(timer);
}, [hint, hideForever, close]);
@@ -71,8 +114,8 @@ export default function DashboardHint() {
// The next card shows under the same pointer: the second click of a double click must not close it unseen.
return (
<HintCard key={hint.id} hint={hint} hideForever={hideForever} onHideForeverChange={setHideForever}
onClose={(event) => event.detail < 2 && close(hideForever)}
onAccept={(event) => event.detail < 2 && close(true)}
onClose={(event) => event.detail < 2 && close(hideForever, true)}
onAccept={(event) => event.detail < 2 && close(true, true)}
className="fixed inset-x-4 bottom-4 z-50 mx-auto max-w-md"/>
);
}
+178 -6
View File
@@ -1,9 +1,10 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, fireEvent, render, screen} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import {toast} from 'sonner';
import DashboardHint, {AUTO_CLOSE_MS} from './DashboardHint.jsx';
import {hintText} from '../utils/hints.js';
import {hintText, pileGrowth} from '../utils/hints.js';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
@@ -12,14 +13,29 @@ vi.mock('sonner', () => ({toast: {error: vi.fn()}}));
const DISMISS = {id: 2, textEn: 'Server update tonight', textDe: 'Heute Nacht Update', kind: 'DISMISS'};
const ACCEPT = {id: 1, textEn: 'Link your Steam account', textDe: null, kind: 'ACCEPT'};
const hintRoutes = (list, done = json(204)) => fetchRoutes({
const USER = {steamId: '1', activeRole: 'USER'};
const AUDITOR = {steamId: '1', activeRole: 'AUDITOR'};
const NO_TREND = {days: []};
const GREW = {days: [{day: '2026-10-01', backlogGames: 10}, {day: '2026-10-02', backlogGames: 12}]};
const hintRoutes = (list, done = json(204), more = {}) => fetchRoutes({
'GET /api/hints': list, 'PUT /api/hints/1/done': done, 'PUT /api/hints/2/done': done,
'PUT /api/hints/auto/NO_STEAM/snooze': done, 'PUT /api/hints/auto/NO_STEAM/done': done, ...more,
});
const doneCalls = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([url]) => url);
const mount = (props = {}) => render(
<MemoryRouter><DashboardHint user={USER} summary={{total: 5}} trend={NO_TREND} {...props}/></MemoryRouter>);
const showHints = async (...hints) => {
hintRoutes(json(200, {results: hints}));
render(<DashboardHint/>);
mount();
await act(async () => undefined);
};
const showAuto = async (automatic, props = {}, results = [], more = {}) => {
hintRoutes(json(200, {results, automatic}), json(204), more);
mount(props);
await act(async () => undefined);
};
@@ -50,7 +66,7 @@ describe('DashboardHint', () => {
fireEvent.click(screen.getByLabelText('HINT.CLOSE'));
cleanup();
render(<DashboardHint/>);
mount();
await act(async () => undefined);
expect(screen.queryByText(DISMISS.textDe)).toBeNull();
@@ -92,7 +108,7 @@ describe('DashboardHint', () => {
it('shows an error when the answer is not stored', async () => {
hintRoutes(json(200, {results: [ACCEPT]}), json(500));
render(<DashboardHint/>);
mount();
await act(async () => undefined);
await act(async () => fireEvent.click(screen.getByText('HINT.OK')));
@@ -113,7 +129,7 @@ describe('DashboardHint', () => {
it('shows nothing when the hints do not load', async () => {
hintRoutes(json(500));
render(<DashboardHint/>);
mount();
await act(async () => undefined);
expect(screen.queryByRole('status')).toBeNull();
@@ -121,6 +137,162 @@ describe('DashboardHint', () => {
});
});
describe('DashboardHint, automatic hints', () => {
beforeEach(() => vi.useFakeTimers({shouldAdvanceTime: true}));
afterEach(() => {
cleanup();
sessionStorage.clear();
vi.useRealTimers();
vi.clearAllMocks();
});
it('shows the admin hints before the automatic ones', async () => {
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}}, [DISMISS]);
expect(screen.getByText('Heute Nacht Update')).toBeTruthy();
fireEvent.click(screen.getByLabelText('HINT.CLOSE'));
expect(screen.getByText('HINT.AUTO_NO_STEAM')).toBeTruthy();
});
it('shows NO_STEAM only without a Steam ID', async () => {
await showAuto(['NO_STEAM']);
expect(screen.queryByRole('status')).toBeNull();
});
it('hides an automatic hint for 7 days with X', async () => {
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}});
await act(async () => fireEvent.click(screen.getByLabelText('HINT.CLOSE')));
expect(screen.queryByRole('status')).toBeNull();
expect(doneCalls()).toEqual([expect.stringContaining('/api/hints/auto/NO_STEAM/snooze')]);
});
it('hides an automatic hint for good with the tick', async () => {
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}});
fireEvent.click(screen.getByLabelText('HINT.DO_NOT_SHOW_AGAIN'));
await act(async () => fireEvent.click(screen.getByLabelText('HINT.CLOSE')));
expect(doneCalls()).toEqual([expect.stringContaining('/api/hints/auto/NO_STEAM/done')]);
});
it('does not store the auto close of an automatic hint and shows it again next session', async () => {
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}});
await act(async () => vi.advanceTimersByTime(AUTO_CLOSE_MS));
expect(screen.queryByRole('status')).toBeNull();
expect(doneCalls()).toEqual([]);
cleanup();
sessionStorage.clear();
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}});
expect(screen.getByText('HINT.AUTO_NO_STEAM')).toBeTruthy();
});
it('hides the auto closed hint for good when the tick is set', async () => {
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}});
fireEvent.click(screen.getByLabelText('HINT.DO_NOT_SHOW_AGAIN'));
await act(async () => vi.advanceTimersByTime(AUTO_CLOSE_MS));
expect(doneCalls()).toEqual([expect.stringContaining('/api/hints/auto/NO_STEAM/done')]);
});
it('does not show an automatic hint that Auth did not send', async () => {
await showAuto([], {user: {...USER, steamId: null}, summary: {total: 0}});
expect(screen.queryByRole('status')).toBeNull();
});
it('shows EMPTY_LIBRARY only with an empty library', async () => {
await showAuto(['EMPTY_LIBRARY'], {summary: {total: 3}});
expect(screen.queryByRole('status')).toBeNull();
cleanup();
await showAuto(['EMPTY_LIBRARY'], {summary: {total: 0}});
expect(screen.getByText('HINT.AUTO_EMPTY_LIBRARY')).toBeTruthy();
});
it('shows an admin hint with emptyLibraryOnly only with an empty library', async () => {
const only = {...DISMISS, emptyLibraryOnly: true};
await showAuto([], {summary: {total: 3}}, [only]);
expect(screen.queryByRole('status')).toBeNull();
cleanup();
await showAuto([], {summary: {total: 0}}, [only]);
expect(screen.getByText('Heute Nacht Update')).toBeTruthy();
});
it('shows nothing while a fact it needs is loading', async () => {
await showAuto(['PILE_GREW'], {trend: undefined});
expect(screen.queryByRole('status')).toBeNull();
cleanup();
await showAuto(['EMPTY_LIBRARY'], {summary: undefined});
expect(screen.queryByRole('status')).toBeNull();
});
it('shows PILE_GREW with the growth since the first day of the month', async () => {
await showAuto(['PILE_GREW'], {trend: GREW});
expect(screen.getByText('HINT.AUTO_PILE_GREW:2')).toBeTruthy();
});
it('does not ask the link queue below AUDITOR', async () => {
await showAuto(['LINK_QUEUE'], {user: USER});
expect(screen.queryByRole('status')).toBeNull();
expect(fetch.mock.calls.some(([url]) => url.includes('link-queue'))).toBe(false);
});
it('shows LINK_QUEUE to an auditor with the number of pairs', async () => {
await showAuto(['LINK_QUEUE'], {user: AUDITOR}, [],
{'GET /api/library/link-queue': json(200, {content: [], totalElements: 3})});
expect(screen.getByText('HINT.AUTO_LINK_QUEUE:3')).toBeTruthy();
});
it('shows no LINK_QUEUE hint when the queue is empty or refused', async () => {
await showAuto(['LINK_QUEUE'], {user: AUDITOR}, [],
{'GET /api/library/link-queue': json(403, {})});
expect(screen.queryByRole('status')).toBeNull();
});
it('shows only the admin hints when Auth sends no automatic list', async () => {
hintRoutes(json(200, {results: [DISMISS]}));
mount({user: {...USER, steamId: null}});
await act(async () => undefined);
expect(screen.getByText('Heute Nacht Update')).toBeTruthy();
fireEvent.click(screen.getByLabelText('HINT.CLOSE'));
expect(screen.queryByRole('status')).toBeNull();
});
});
describe('pileGrowth', () => {
it('counts from the first day of the last month and is 0 without a second day', () => {
expect(pileGrowth(GREW)).toBe(2);
expect(pileGrowth({days: [{day: '2026-09-30', backlogGames: 5}, {day: '2026-10-02', backlogGames: 4}]})).toBe(-1);
expect(pileGrowth({days: [{day: '2026-10-02', backlogGames: 4}]})).toBe(0);
expect(pileGrowth({days: [{day: '2026-10-01', backlogGames: 9}, {day: '2026-10-02', backlogGames: 7}]})).toBe(-2);
expect(pileGrowth(null)).toBe(0);
});
it('counts games added on the first day of the month', () => {
// Pile 5 at the end of September, 3 games added on October 1, nothing after.
const trend = {days: [{day: '2026-09-30', backlogGames: 5}, {day: '2026-10-01', backlogGames: 8},
{day: '2026-10-02', backlogGames: 8}]};
expect(pileGrowth(trend)).toBe(3);
// On October 1 itself: last month's snapshot and today's live value.
expect(pileGrowth({days: [{day: '2026-09-30', backlogGames: 5}, {day: '2026-10-01', backlogGames: 8}]})).toBe(3);
});
});
describe('hintText', () => {
it('takes the German text for a German UI only when there is one', () => {
expect(hintText(DISMISS, 'de-DE')).toBe(DISMISS.textDe);
+3 -1
View File
@@ -1,6 +1,6 @@
import {cn} from '@/lib/utils';
export const EFFECTS = ['triangles', 'starfield', 'pixels', 'aurora', 'mesh', 'grid', 'waves', 'bokeh'];
export const EFFECTS = ['triangles', 'starfield', 'pixels', 'aurora', 'mesh', 'grid', 'waves', 'bokeh', 'equalizer'];
const range = (count) => Array.from({length: count}, (_, i) => i);
@@ -73,6 +73,8 @@ function shapes(effect) {
<path d={WAVE} fill="currentColor"/>
</svg>
));
case 'equalizer':
return range(16).map((i) => <span key={i}/>);
case 'bokeh':
return range(10).map((i) => (
<span key={i} style={{
+1 -1
View File
@@ -21,7 +21,7 @@ import {coverEffect, detailEffect} from '../utils/rewards.js';
const SPEEDRUN_PREFIX = 'https://www.speedrun.com/';
// The spans of each completed effect decoration; effects.css places them.
const DETAIL_ELEMENTS = {'fx-detail-gold-shine': 8, 'fx-detail-neon-pulse': 1, 'fx-detail-holo': 1, 'fx-detail-ember': 6};
const DETAIL_ELEMENTS = {'fx-detail-gold-shine': 8, 'fx-detail-neon-pulse': 1, 'fx-detail-holo': 1, 'fx-detail-ember': 6, 'fx-detail-platinum': 6};
export default function GameHeroCard({game, auditor, profileStyle, onCorrect, onMerge, onHiddenChanged, onProfileStyleSaved}) {
const {t} = useTranslation();
+4 -1
View File
@@ -5,6 +5,7 @@ import {Megaphone, X} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {cn} from '@/lib/utils';
import HintText from './HintText.jsx';
import {hintText} from '../utils/hints.js';
export default function HintCard({hint, hideForever, onHideForeverChange, onClose, onAccept, className}) {
@@ -15,7 +16,9 @@ export default function HintCard({hint, hideForever, onHideForeverChange, onClos
<Card role="status" className={cn('gap-3 p-4 shadow-lg', className)}>
<div className="flex items-start gap-3">
<Megaphone className="mt-0.5 size-5 shrink-0 text-primary"/>
<p className="flex-1 whitespace-pre-line break-words text-sm">{hintText(hint, i18n.language)}</p>
<p className="flex-1 whitespace-pre-line break-words text-sm">
<HintText text={hint.text ?? hintText(hint, i18n.language)}/>
</p>
{hint.kind === 'DISMISS' && (
<Button type="button" variant="ghost" size="icon" className="-mr-2 -mt-2 size-8"
aria-label={t('HINT.CLOSE')} onClick={onClose}>
+42
View File
@@ -0,0 +1,42 @@
import {Link} from 'react-router-dom';
// **bold** and [text](url) only. No nesting: the link text is plain.
const TOKEN = /\*\*([^*\n]+)\*\*|\[([^\]\n]+)\]\(([^)\s]+)\)/g;
// An app path (not //host, and no backslash: browsers read /\host as //host), or https. Case-sensitive on purpose.
const APP_PATH = /^\/(?![/\\])[^\s\\]*$/;
const HTTPS = /^https:\/\/[^\s\\]+$/;
/**
* @returns {{to: string} | {href: string} | null} null: the url is not safe, so the link stays plain text
*/
export const safeLink = (url) => {
if (APP_PATH.test(url)) return {to: url};
if (HTTPS.test(url)) return {href: url};
return null;
};
/**
* Hint and news text with **bold** and [text](url). Only React elements, never HTML: React escapes every string.
*/
export default function HintText({text}) {
const parts = [];
let last = 0;
for (const match of text.matchAll(TOKEN)) {
const [whole, bold, label, url] = match;
if (match.index > last) parts.push(text.slice(last, match.index));
last = match.index + whole.length;
const link = url && safeLink(url);
if (bold !== undefined) {
parts.push(<strong key={match.index}>{bold}</strong>);
} else if (link?.to) {
parts.push(<Link key={match.index} to={link.to} className="underline">{label}</Link>);
} else if (link) {
parts.push(<a key={match.index} href={link.href} target="_blank" rel="noopener noreferrer"
className="underline">{label}</a>);
} else {
parts.push(whole);
}
}
parts.push(text.slice(last));
return <>{parts}</>;
}
+57
View File
@@ -0,0 +1,57 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it} from 'vitest';
import {cleanup, render} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import HintText, {safeLink} from './HintText.jsx';
const renderText = (text) => render(<MemoryRouter><p>{<HintText text={text}/>}</p></MemoryRouter>).container;
const UNSAFE = [
'javascript:alert(1)', 'JavaScript:alert(1)', 'data:text/html,<b>x</b>', 'http://example.com', '//evil.example',
'/\\evil.example', 'HTTPS://x', 'vbscript:x',
];
describe('HintText', () => {
afterEach(cleanup);
it('renders bold and links', () => {
const container = renderText('A **bold** word, [Import](/import) and [site](https://example.com).');
expect(container.querySelector('strong').textContent).toBe('bold');
expect(container.querySelector('a[href="/import"]').textContent).toBe('Import');
const external = container.querySelector('a[href="https://example.com"]');
expect(external.getAttribute('target')).toBe('_blank');
expect(external.getAttribute('rel')).toBe('noopener noreferrer');
expect(container.textContent).toBe('A bold word, Import and site.');
});
it.each(UNSAFE)('shows a link to %s as plain text', (url) => {
const text = `[click](${url})`;
const container = renderText(text);
expect(container.querySelector('a')).toBeNull();
expect(container.textContent).toBe(text);
});
it('shows HTML as text', () => {
const container = renderText('<img src=x onerror=alert(1)> <script>alert(1)</script>');
expect(container.querySelector('img, script')).toBeNull();
expect(container.textContent).toContain('<img src=x onerror=alert(1)>');
});
it('keeps unclosed markup and line breaks as they are', () => {
const container = renderText('**bold\n[text](');
expect(container.querySelector('strong, a')).toBeNull();
expect(container.textContent).toBe('**bold\n[text](');
});
});
describe('safeLink', () => {
it('allows app paths and https only', () => {
expect(safeLink('/import')).toEqual({to: '/import'});
expect(safeLink('https://example.com/a')).toEqual({href: 'https://example.com/a'});
UNSAFE.forEach((url) => expect(safeLink(url)).toBeNull());
});
});
+2 -1
View File
@@ -1,6 +1,7 @@
import {useTranslation} from 'react-i18next';
import {cn} from '@/lib/utils';
import HintText from './HintText.jsx';
import {formatDate} from '../utils/format.js';
import {newsText, newsTitle} from '../utils/news.js';
@@ -11,7 +12,7 @@ export default function NewsEntry({entry, className}) {
<article className={cn('space-y-1', className)}>
<p className="text-xs text-muted-foreground">{formatDate(entry.publishedAt, i18n.language, {dateStyle: 'medium'})}</p>
<h3 className="font-semibold">{newsTitle(entry, i18n.language)}</h3>
<p className="whitespace-pre-line break-words text-sm">{newsText(entry, i18n.language)}</p>
<p className="whitespace-pre-line break-words text-sm"><HintText text={newsText(entry, i18n.language)}/></p>
</article>
);
}
+1 -1
View File
@@ -8,7 +8,7 @@ function Card({
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-(--card-shadow)",
className
)}
{...props}
+2 -2
View File
@@ -12,7 +12,7 @@ import {
import {hasSession} from '../utils/auth.js';
import useSession from '../hooks/useSession.js';
import {LANGUAGES, saveSettings} from '../utils/settings.js';
import {cacheEquipped, equippedAccent, loadWallet, readCachedEquipped} from '../utils/rewards.js';
import {cacheEquipped, equippedAccent, equippedSkin, loadWallet, readCachedEquipped} from '../utils/rewards.js';
import {storage} from '../utils/storage.js';
const DARK_MODE_STORAGE_KEY = 'tbp_dark_mode';
@@ -57,7 +57,7 @@ export function PreferencesProvider({children}) {
// Layout effect: the cached look is set before the first paint.
useLayoutEffect(() => {
applyAppearance(appearance, equippedAccent(equipped));
applyAppearance(appearance, {accent: equippedAccent(equipped), skin: equippedSkin(equipped)});
cacheAppearance(appearance);
cacheEquipped(equipped);
}, [appearance, equipped]);
+11
View File
@@ -137,4 +137,15 @@ describe('PreferencesProvider worn looks', () => {
expect(preferences.equipped).toEqual({});
});
it('wears the skin on the root and takes it off on reset', () => {
localStorage.setItem('tbp_session', '1');
localStorage.setItem('tbp_equipped', JSON.stringify({SKIN: 'SKIN_RETRO_CRT'}));
render(<PreferencesProvider><Spy/></PreferencesProvider>);
expect(document.documentElement.dataset.skin).toBe('retro-crt');
act(() => preferences.resetAppearance());
expect(document.documentElement.dataset.skin).toBeUndefined();
});
});
+60
View File
@@ -16,6 +16,7 @@
/* ---- COMPLETED_EFFECT, cover of a tile (GameCover has relative and overflow-hidden) ---- */
.fx-cover-gold-shine::after,
.fx-cover-platinum::after,
.fx-cover-neon-pulse::after,
.fx-cover-holo::after,
.fx-cover-ember::before,
@@ -33,6 +34,14 @@
animation: fx-sweep 4s ease-in-out infinite;
}
/* The silver border is an inside outline in the look; a cool light sweeps over the cover. */
.fx-cover-platinum::after {
inset: 0;
background: linear-gradient(115deg, transparent 38%, rgb(226 232 240 / 0.4) 48%, rgb(186 230 253 / 0.3) 54%, transparent 62%);
transform: translateX(-100%);
animation: fx-sweep 5s 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;
@@ -98,6 +107,26 @@
.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-platinum > span {
position: absolute;
left: var(--x);
top: var(--y);
width: 12px;
height: 12px;
background: #6b7a90;
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 3s ease-in-out infinite;
animation-delay: var(--d);
}
.dark .fx-detail-platinum > span { background: #e2e8f0; }
.fx-detail-platinum > :nth-child(1) { --x: 48%; --y: 16%; --d: 0s; }
.fx-detail-platinum > :nth-child(2) { --x: 66%; --y: 64%; --d: -0.5s; }
.fx-detail-platinum > :nth-child(3) { --x: 80%; --y: 22%; --d: -1s; }
.fx-detail-platinum > :nth-child(4) { --x: 90%; --y: 58%; --d: -1.5s; }
.fx-detail-platinum > :nth-child(5) { --x: 56%; --y: 44%; --d: -2s; }
.fx-detail-platinum > :nth-child(6) { --x: 84%; --y: 84%; --d: -2.5s; }
.fx-detail-neon-pulse > span {
position: absolute;
inset: 0;
@@ -333,6 +362,37 @@
background: radial-gradient(closest-side, color-mix(in oklab, var(--logo-teal) 22%, transparent), transparent);
}
/* equalizer: 16 bars at the bottom, scaleY only. The heights are fixed, so frame 0 is a static skyline. */
@keyframes fx-bar { from { transform: scaleY(var(--lo)); } to { transform: scaleY(var(--hi)); } }
.fx-bg-equalizer > * {
bottom: 0;
left: calc(var(--i) * 6.25% + 1%);
width: 4.25%;
height: 55%;
transform-origin: bottom;
transform: scaleY(var(--hi));
border-radius: 2px 2px 0 0;
background: linear-gradient(to top, color-mix(in oklab, var(--primary) 35%, transparent), color-mix(in oklab, var(--logo-teal) 12%, transparent));
animation: fx-bar var(--dur) ease-in-out infinite alternate;
animation-delay: var(--d);
}
.fx-bg-equalizer > :nth-child(1) { --i: 0; --hi: 0.55; --lo: 0.2; --dur: 1.1s; --d: 0s; }
.fx-bg-equalizer > :nth-child(2) { --i: 1; --hi: 0.8; --lo: 0.3; --dur: 1.4s; --d: -0.3s; }
.fx-bg-equalizer > :nth-child(3) { --i: 2; --hi: 0.4; --lo: 0.15; --dur: 0.9s; --d: -0.6s; }
.fx-bg-equalizer > :nth-child(4) { --i: 3; --hi: 0.95; --lo: 0.4; --dur: 1.7s; --d: -0.2s; }
.fx-bg-equalizer > :nth-child(5) { --i: 4; --hi: 0.65; --lo: 0.25; --dur: 1.2s; --d: -0.9s; }
.fx-bg-equalizer > :nth-child(6) { --i: 5; --hi: 0.3; --lo: 0.1; --dur: 0.8s; --d: -0.4s; }
.fx-bg-equalizer > :nth-child(7) { --i: 6; --hi: 0.75; --lo: 0.35; --dur: 1.5s; --d: -1.1s; }
.fx-bg-equalizer > :nth-child(8) { --i: 7; --hi: 1; --lo: 0.45; --dur: 1.9s; --d: -0.7s; }
.fx-bg-equalizer > :nth-child(9) { --i: 8; --hi: 0.5; --lo: 0.2; --dur: 1s; --d: -1.3s; }
.fx-bg-equalizer > :nth-child(10) { --i: 9; --hi: 0.85; --lo: 0.3; --dur: 1.6s; --d: -0.1s; }
.fx-bg-equalizer > :nth-child(11) { --i: 10; --hi: 0.35; --lo: 0.12; --dur: 0.85s; --d: -0.8s; }
.fx-bg-equalizer > :nth-child(12) { --i: 11; --hi: 0.7; --lo: 0.28; --dur: 1.3s; --d: -1.5s; }
.fx-bg-equalizer > :nth-child(13) { --i: 12; --hi: 0.9; --lo: 0.4; --dur: 1.8s; --d: -0.5s; }
.fx-bg-equalizer > :nth-child(14) { --i: 13; --hi: 0.45; --lo: 0.18; --dur: 1.05s; --d: -1.2s; }
.fx-bg-equalizer > :nth-child(15) { --i: 14; --hi: 0.6; --lo: 0.22; --dur: 1.25s; --d: -0.35s; }
.fx-bg-equalizer > :nth-child(16) { --i: 15; --hi: 0.25; --lo: 0.1; --dur: 0.95s; --d: -1.0s; }
/* ---- Off screen covers hold their frame (components/GameCover.jsx) ---- */
.fx-paused,
+69 -3
View File
@@ -12,7 +12,8 @@ import {Textarea} from '@/components/ui/textarea';
import HintCard from '../components/HintCard.jsx';
import {formatDateTime} from '../utils/format.js';
import PageContainer from '../components/PageContainer.jsx';
import {createHint, endHint, fetchAdminHints} from '../utils/hints.js';
import {createHint, endHint, fetchAdminHints, setAutoHintEnabled} from '../utils/hints.js';
import {ROLES} from '../utils/admin.js';
import {MAX_HINT_LENGTH} from '../utils/limits.js';
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
@@ -25,7 +26,18 @@ const toLocalInput = (date) => {
const emptyForm = () => {
const now = new Date();
return {textEn: '', textDe: '', kind: 'DISMISS', startsAt: toLocalInput(now), endsAt: toLocalInput(new Date(now.getTime() + WEEK_MS))};
return {textEn: '', textDe: '', kind: 'DISMISS', startsAt: toLocalInput(now), endsAt: toLocalInput(new Date(now.getTime() + WEEK_MS)),
targetNoSteam: false, targetRole: '', targetNewDays: '', targetEmptyLibrary: false};
};
const targetSummary = (hint, t) => {
const groups = [
hint.targetNoSteam && t('ADMIN_HINTS.TARGET_SUMMARY_NO_STEAM'),
hint.targetRole && t('ADMIN_HINTS.TARGET_SUMMARY_ROLE', {role: t(`ROLES.${hint.targetRole}`)}),
hint.targetNewDays && t('ADMIN_HINTS.TARGET_SUMMARY_NEW', {count: hint.targetNewDays}),
hint.targetEmptyLibrary && t('ADMIN_HINTS.TARGET_SUMMARY_EMPTY_LIBRARY'),
].filter(Boolean);
return groups.length ? groups.join(' + ') : t('ADMIN_HINTS.TARGET_ALL');
};
/**
@@ -34,6 +46,7 @@ const emptyForm = () => {
export default function AdminHintsPage() {
const {t, i18n} = useTranslation();
const [hints, setHints] = useState([]);
const [automatic, setAutomatic] = useState([]);
const [status, setStatus] = useState('loading');
const [form, setForm] = useState(emptyForm);
const [busy, setBusy] = useState(false);
@@ -50,7 +63,9 @@ export default function AdminHintsPage() {
setStatus('error');
return;
}
setHints((await response.json()).results);
const data = await response.json();
setHints(data.results);
setAutomatic(data.automatic ?? []);
setStatus('ready');
} catch (err) {
console.error('Admin hints error:', err);
@@ -91,10 +106,15 @@ export default function AdminHintsPage() {
kind: form.kind,
startsAt: new Date(form.startsAt).toISOString(),
endsAt: new Date(form.endsAt).toISOString(),
targetNoSteam: form.targetNoSteam,
targetRole: form.targetRole || null,
targetNewDays: form.targetNewDays ? Number(form.targetNewDays) : null,
targetEmptyLibrary: form.targetEmptyLibrary,
}), 'ADMIN_HINTS.CREATED', () => setForm(emptyForm()));
};
const change = (field) => (event) => setForm({...form, [field]: event.target.value});
const tick = (field) => (event) => setForm({...form, [field]: event.target.checked});
const formatDate = (value) => formatDateTime(value, i18n.language, {dateStyle: 'short', timeStyle: 'short'});
if (status === 'forbidden') {
@@ -132,6 +152,35 @@ export default function AdminHintsPage() {
</label>
))}
</fieldset>
<fieldset className="space-y-2">
<legend className="text-sm font-medium">{t('ADMIN_HINTS.TARGET')}</legend>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" className="accent-primary" checked={form.targetNoSteam}
onChange={tick('targetNoSteam')}/>
{t('ADMIN_HINTS.TARGET_NO_STEAM')}
</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" className="accent-primary" checked={form.targetEmptyLibrary}
onChange={tick('targetEmptyLibrary')}/>
{t('ADMIN_HINTS.TARGET_EMPTY_LIBRARY')}
</label>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="hintTargetRole">{t('ADMIN_HINTS.TARGET_ROLE')}</Label>
<select id="hintTargetRole" value={form.targetRole} onChange={change('targetRole')}
className="h-9 w-full rounded-md border bg-transparent px-3 text-sm">
<option value="">{t('ADMIN_HINTS.TARGET_ROLE_ALL')}</option>
{ROLES.map((role) => <option key={role} value={role}>{t(`ROLES.${role}`)}</option>)}
</select>
</div>
<div className="space-y-2">
<Label htmlFor="hintTargetNewDays">{t('ADMIN_HINTS.TARGET_NEW_DAYS')}</Label>
<Input id="hintTargetNewDays" type="number" min={1} max={365} value={form.targetNewDays}
onChange={change('targetNewDays')}/>
</div>
</div>
<p className="text-xs text-muted-foreground">{t('ADMIN_HINTS.TARGET_HELP')}</p>
</fieldset>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="hintStartsAt">{t('ADMIN_HINTS.STARTS_AT')}</Label>
@@ -157,6 +206,22 @@ export default function AdminHintsPage() {
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('ADMIN_HINTS.AUTO_TITLE')}</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{automatic.map(({key, enabled}) => (
<label key={key} className="flex items-center gap-2 text-sm">
<input type="checkbox" className="accent-primary" checked={enabled} disabled={busy}
onChange={(event) => run(() => setAutoHintEnabled(key, event.target.checked),
'ADMIN_HINTS.AUTO_SAVED')}/>
{t(`ADMIN_HINTS.AUTO_${key}`)}
</label>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('ADMIN_HINTS.LIST')}</CardTitle>
@@ -176,6 +241,7 @@ export default function AdminHintsPage() {
{[t('ADMIN_HINTS.RANGE', {start: formatDate(hint.startsAt), end: formatDate(hint.endsAt)}),
t(`ADMIN_HINTS.KIND_${hint.kind}`),
t('ADMIN_HINTS.DONE_COUNT', {count: hint.doneCount}),
targetSummary(hint, t),
].join(' · ')}
</p>
</div>
+50 -1
View File
@@ -17,9 +17,11 @@ const ACTIVE = {id: 2, textEn: 'Server update tonight', textDe: null, kind: 'DIS
const ENDED = {...ACTIVE, id: 1, textEn: 'Old news', startsAt: at(-3), endsAt: at(-2), status: 'ENDED', doneCount: 0};
const withRoutes = (routes) => fetchRoutes({
'GET /api/admin/hints': json(200, {results: [ACTIVE, ENDED]}),
'GET /api/admin/hints': json(200, {results: [ACTIVE, ENDED],
automatic: [{key: 'NO_STEAM', enabled: true}, {key: 'PILE_GREW', enabled: true}]}),
'POST /api/admin/hints': json(201, {id: 3}),
'PUT /api/admin/hints/2/end': json(204),
'PUT /api/admin/hints/auto/PILE_GREW': json(204),
...routes,
});
const calls = (method) => fetch.mock.calls.filter(([, init]) => (init?.method ?? 'GET') === method);
@@ -69,6 +71,53 @@ describe('AdminHintsPage', () => {
expect(screen.getByLabelText('ADMIN_HINTS.TEXT_EN').value).toBe('');
});
it('sends the target groups, empty ones as null', async () => {
renderPage();
await screen.findByText('Server update tonight');
fireEvent.change(screen.getByLabelText('ADMIN_HINTS.TEXT_EN'), {target: {value: 'News'}});
fireEvent.click(screen.getByLabelText('ADMIN_HINTS.TARGET_NO_STEAM'));
fireEvent.change(screen.getByLabelText('ADMIN_HINTS.TARGET_NEW_DAYS'), {target: {value: '7'}});
fireEvent.change(screen.getByLabelText('ADMIN_HINTS.TARGET_ROLE'), {target: {value: 'AUDITOR'}});
fireEvent.click(screen.getByText('ADMIN_HINTS.CREATE'));
await waitFor(() => expect(calls('POST')).toHaveLength(1));
expect(JSON.parse(calls('POST')[0][1].body)).toMatchObject({
targetNoSteam: true, targetRole: 'AUDITOR', targetNewDays: 7, targetEmptyLibrary: false});
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('ADMIN_HINTS.CREATED'));
fireEvent.change(screen.getByLabelText('ADMIN_HINTS.TEXT_EN'), {target: {value: 'More news'}});
fireEvent.click(screen.getByText('ADMIN_HINTS.CREATE'));
await waitFor(() => expect(calls('POST')).toHaveLength(2));
expect(JSON.parse(calls('POST')[1][1].body)).toMatchObject({
targetNoSteam: false, targetRole: null, targetNewDays: null, targetEmptyLibrary: false});
});
it('shows the role labels for the role target', async () => {
renderPage();
expect(await screen.findByRole('option', {name: 'ROLES.AUDITOR'})).toBeTruthy();
});
it('turns an automatic hint off', async () => {
renderPage();
await screen.findByText('Server update tonight');
fireEvent.click(screen.getByLabelText('ADMIN_HINTS.AUTO_PILE_GREW'));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('ADMIN_HINTS.AUTO_SAVED'));
const [url, init] = calls('PUT')[0];
expect(url).toContain('/api/admin/hints/auto/PILE_GREW');
expect(JSON.parse(init.body)).toEqual({enabled: false});
});
it('previews bold text', async () => {
renderPage();
await screen.findByText('Server update tonight');
fireEvent.change(screen.getByLabelText('ADMIN_HINTS.TEXT_EN'), {target: {value: 'Read **this**'}});
expect(document.querySelector('[role="status"] strong').textContent).toBe('this');
});
it('explains an invalid time range', async () => {
withRoutes({'GET /api/admin/hints': json(200, {results: []}), 'POST /api/admin/hints': json(400, {code: 'INVALID_HINT'})});
renderPage();
+7 -10
View File
@@ -41,14 +41,10 @@ const Dashboard = () => {
const [editing, setEditing] = useState(false);
const runSave = useSaveResponse();
const layout = normalizeLayout(userData?.settings?.dashboardTiles, DASHBOARD_TILES, DEFAULT_DASHBOARD);
// The stat groups of the shown stat tiles, and TREND for the pile card. Waits for the saved layout in /me.
const statGroups = [...new Set([
...layout.map((tile) => statGroup(tile.id)).filter(Boolean),
...(layout.some((tile) => tile.id === 'PILE_OF_SHAME') ? ['TREND'] : []),
])].sort();
// No groups would mean "all groups" to the stats endpoint.
const hasStatGroups = statGroups.length > 0;
const stats = useUserStats(statGroups, userData != null && hasStatGroups);
// The stat groups of the shown stat tiles, and TREND for the pile card and the "pile grew" hint. Waits for the
// saved layout in /me.
const statGroups = [...new Set([...layout.map((tile) => statGroup(tile.id)).filter(Boolean), 'TREND'])].sort();
const stats = useUserStats(statGroups, userData != null);
const shuffleSuggestion = async () => {
setShuffling(true);
@@ -70,7 +66,7 @@ const Dashboard = () => {
expectInitialSync: hasSteamId && steamData.count === 0,
onComplete: () => {
reloadLibrary();
if (hasStatGroups) stats.reload();
stats.reload();
},
});
const forceSync = useForceSync(sync.watch);
@@ -135,7 +131,8 @@ const Dashboard = () => {
defaultLayout={DEFAULT_DASHBOARD} onSave={saveLayout} onCancel={() => setEditing(false)}/>
<WhatsNewDialog/>
{/* Last child: space-y gives every other child a bottom margin, which would move the fixed card. */}
<DashboardHint/>
<DashboardHint user={userData} summary={summaryFailed ? null : summary ?? undefined}
trend={stats.failed ? null : stats.stats ? stats.stats.trend ?? null : undefined}/>
</PageContainer>
);
};
+2 -2
View File
@@ -92,8 +92,8 @@ describe('Dashboard', () => {
expect(order(container)).toEqual(['STATS_HABITS', 'MOST_PLAYED']);
expect(screen.getByText('Game 2')).toBeTruthy();
expect(screen.queryByText('Game 3')).toBeNull();
// No pile tile, so no TREND.
expect(fetch).toHaveBeenCalledWith(expect.stringMatching(/\/api\/library\/stats\?groups=HABITS$/), expect.anything());
// TREND is always asked: the "pile grew" hint needs it.
expect(fetch).toHaveBeenCalledWith(expect.stringMatching(/\/api\/library\/stats\?groups=HABITS,TREND$/), expect.anything());
});
it('leaves out a stat tile whose data did not come', async () => {
+35 -3
View File
@@ -1,7 +1,7 @@
import {useRef} from 'react';
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {Coins} from 'lucide-react';
import {Coins, LockKeyhole} from 'lucide-react';
import {toast} from 'sonner';
import {Badge} from '@/components/ui/badge';
@@ -17,7 +17,7 @@ import {formatDate} from '../utils/format.js';
import {buyItem, equipItem, LOOKS, SLOTS} from '../utils/rewards.js';
const GAME_KINDS = ['STARTED', 'FINISHED', 'FINISHED_MANUAL', 'COMPLETED'];
const HOW_TO = ['STARTED', 'FINISHED', 'COMPLETED', 'FINISHED_MANUAL', 'MONTH', 'STREAK'];
const HOW_TO = ['STARTED', 'FINISHED', 'COMPLETED', 'FINISHED_MANUAL', 'MONTH', 'STREAK', 'UNLOCK'];
export default function PointsPage() {
const {t, i18n} = useTranslation();
@@ -58,7 +58,7 @@ export default function PointsPage() {
</Link>
);
}
if (entry.kind === 'BUY') return t(`POINTS.ITEMS.${entry.ref}`);
if (['BUY', 'UNLOCK'].includes(entry.kind)) return t(`POINTS.ITEMS.${entry.ref}`);
if (['MONTH', 'STREAK'].includes(entry.kind)) return entry.ref;
return null;
};
@@ -89,6 +89,7 @@ export default function PointsPage() {
{wallet.catalog.filter((item) => item.slot === slot).map((item) => {
const owned = wallet.owned.includes(item.code);
const worn = wallet.equipped[slot] === item.code;
const locked = !owned && item.track;
return (
<li key={item.code} className="flex items-center gap-3 py-3">
<Preview code={item.code}/>
@@ -99,6 +100,8 @@ export default function PointsPage() {
onClick={() => act(() => equipItem(slot, worn ? null : item.code))}>
{t(worn ? 'POINTS.UNEQUIP' : 'POINTS.EQUIP')}
</Button>
) : locked ? (
<LockedProgress item={item} progress={wallet.progress?.[item.track] ?? 0}/>
) : (
<>
<span className="text-sm tabular-nums text-muted-foreground">{item.price}</span>
@@ -133,9 +136,11 @@ export default function PointsPage() {
{t(`POINTS.KINDS.${entry.kind}`)}
{label && <> {label}</>}
</span>
{entry.points !== 0 && (
<span className={cn('font-semibold tabular-nums', entry.points < 0 && 'text-muted-foreground')}>
{entry.points > 0 ? `+${entry.points}` : entry.points}
</span>
)}
</li>
);
})}
@@ -157,6 +162,14 @@ function Preview({code}) {
if (look.Icon) {
return <span className={cn('flex size-8 items-center justify-center rounded-full', look.className)}><look.Icon className="size-4"/></span>;
}
if (look.skin) {
return (
<span data-skin={look.skin}
className="skin-bg bg-scroll flex size-8 shrink-0 items-center justify-center rounded-[var(--radius)] border bg-background">
<span className="size-3 rounded-full bg-primary"/>
</span>
);
}
if (look.accent) {
return <span data-accent={look.accent} className="size-8 rounded-full bg-primary"/>;
}
@@ -172,3 +185,22 @@ function Preview({code}) {
}
return <span className={cn('size-8 shrink-0 bg-muted', look.className, code.startsWith('BORDER') ? 'rounded-full' : 'rounded-md')}/>;
}
// An unlock-only item that is not earned yet: the lock, the progress as text and as a bar. Never a price or a Buy button.
function LockedProgress({item, progress}) {
const {t} = useTranslation();
const count = Math.min(progress, item.target);
const text = t(`POINTS.PROGRESS.${item.track}`, {count, target: item.target});
return (
<div className="flex w-40 shrink-0 flex-col gap-1 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<LockKeyhole className="size-3 shrink-0"/>
<span title={text}>{text}</span>
</span>
<div role="progressbar" aria-label={t('POINTS.LOCKED')} aria-valuemin={0} aria-valuenow={count}
aria-valuemax={item.target} className="h-1.5 overflow-hidden rounded-full bg-muted">
<div className="h-full bg-primary" style={{width: `${(count / item.target) * 100}%`}}/>
</div>
</div>
);
}
+47
View File
@@ -19,12 +19,17 @@ const wallet = (extra = {}) => ({
{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},
{code: 'BADGE_FINISH_10', slot: 'BADGE', price: 0, track: 'GAMES_FINISHED', target: 10},
{code: 'BADGE_STREAK_4', slot: 'BADGE', price: 0, track: 'FINISH_WEEKS', target: 4},
{code: 'SKIN_PASTEL', slot: 'SKIN', price: 600},
],
progress: {GAMES_FINISHED: 7, FINISH_WEEKS: 9},
owned: ['BADGE_PILE_SLAYER'],
equipped: {},
history: [
{kind: 'FINISHED', ref: '620', gameName: 'Portal 2', points: 50, eventAt: '2026-09-30T10:00:00Z'},
{kind: 'MONTH', ref: '2026-09', gameName: null, points: 40, eventAt: '2026-09-30T10:00:00Z'},
{kind: 'UNLOCK', ref: 'BADGE_PERFECT_5', gameName: null, points: 0, eventAt: '2026-09-29T10:00:00Z'},
],
...extra,
});
@@ -60,6 +65,48 @@ describe('PointsPage', () => {
expect(card('BANNER_BACKGROUND').querySelector('[data-effect="grid"]')).not.toBeNull();
});
it('shows a locked unlock item with its progress, a progress bar and no price or Buy button', async () => {
await renderPage();
const item = screen.getByText('POINTS.ITEMS.BADGE_FINISH_10').closest('li');
expect(within(item).getByText('POINTS.PROGRESS.GAMES_FINISHED:7')).toBeTruthy();
const bar = within(item).getByRole('progressbar');
expect(bar.getAttribute('aria-valuenow')).toBe('7');
expect(bar.getAttribute('aria-valuemax')).toBe('10');
expect(within(item).queryByRole('button')).toBeNull();
});
it('caps the progress at the target', async () => {
await renderPage();
const item = screen.getByText('POINTS.ITEMS.BADGE_STREAK_4').closest('li');
expect(within(item).getByText('POINTS.PROGRESS.FINISH_WEEKS:4')).toBeTruthy();
expect(within(item).getByRole('progressbar').getAttribute('aria-valuenow')).toBe('4');
});
it('lets the user equip an unlock item once it is owned', async () => {
await renderPage({'GET /api/rewards/wallet': json(200, wallet({owned: ['BADGE_FINISH_10']}))});
const item = screen.getByText('POINTS.ITEMS.BADGE_FINISH_10').closest('li');
expect(within(item).getByRole('button', {name: 'POINTS.EQUIP'})).toBeTruthy();
expect(within(item).queryByRole('progressbar')).toBeNull();
});
it('previews a skin with its data-skin', async () => {
await renderPage();
const card = screen.getByText('POINTS.SLOTS.SKIN').closest('[data-slot="card"]');
expect(card.querySelector('[data-skin="pastel"]')).not.toBeNull();
});
it('shows an UNLOCK row with the item name and no number', async () => {
await renderPage();
const row = screen.getByText(/POINTS.KINDS.UNLOCK/).closest('li');
expect(row.textContent).toContain('POINTS.ITEMS.BADGE_PERFECT_5');
expect(row.textContent).not.toMatch(/\+?0$/);
});
it('holds the frame of previews that are off screen', async () => {
const callbacks = [];
vi.stubGlobal('IntersectionObserver', class {
+5 -2
View File
@@ -10,7 +10,7 @@ import {cn} from '@/lib/utils';
import GameCover from '../components/GameCover.jsx';
import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx';
import {formatDay} from '../utils/format.js';
import {backgroundEffect, bannerEffect, coverEffect, equippedAccent, lookClass} from '../utils/rewards.js';
import {backgroundEffect, bannerEffect, coverEffect, equippedAccent, equippedSkin, lookClass} from '../utils/rewards.js';
import PointsBadge from '../components/PointsBadge.jsx';
import {GAME_STATUSES} from '../utils/limits.js';
import {formatPlaytime} from '../utils/games.js';
@@ -152,7 +152,10 @@ export default function PublicProfilePage() {
// The owner's accent and corners apply to this page only; light or dark mode stays the viewer's.
return (
<div data-accent={equippedAccent(equipped) ?? style.accent} data-corners={style.corners}>
<div data-accent={equippedAccent(equipped) ?? style.accent} data-corners={style.corners}
data-skin={equippedSkin(equipped) ?? 'none'}>
{/* The owner's base layer, below PageBackground (-z-10); the page's own bg-background is the viewer's. */}
<div aria-hidden="true" className="pointer-events-none fixed inset-0 -z-20 bg-background skin-bg"/>
<PageBackground appid={style.backgroundAppid} effect={backgroundEffect(equipped)}/>
<PageContainer className="p-4 md:p-8 space-y-6">
<Card className={cn('relative overflow-hidden p-6 md:p-8', lookClass(equipped, 'BANNER_FRAME'))}>
+17
View File
@@ -55,6 +55,23 @@ describe('PublicProfilePage', () => {
expect(screen.getByText('Portal 2').closest('li').querySelector('.fx-cover-neon-pulse')).not.toBeNull();
});
it('wears the skin of the owner on the wrapper with its own base layer, or resets to the plain theme', async () => {
mockBackends({style: {accent: 'red'}, equipped: {SKIN: 'SKIN_CYBERPUNK'}});
const {unmount} = renderPage();
expect(await screen.findByText('Portal 2')).toBeTruthy();
const wrapper = screen.getByText('Lobo').closest('[data-skin]');
expect(wrapper.dataset.skin).toBe('cyberpunk');
expect(wrapper.querySelector('.skin-bg')).not.toBeNull();
unmount();
cleanup();
mockBackends({style: {accent: 'red'}, equipped: {}});
renderPage();
expect(await screen.findByText('Portal 2')).toBeTruthy();
expect(screen.getByText('Lobo').closest('[data-skin]').dataset.skin).toBe('none');
});
it('still shows the profile when the looks call fails', async () => {
mockBackends({style: {accent: 'red'}, rewardsStatus: 500});
renderPage();
+268
View File
@@ -0,0 +1,268 @@
/*
* Skins of the points shop (utils/rewards.js LOOKS, data-skin on <html>, or on the wrapper of a public profile).
* A skin is a full theme made of CSS variables only: every shadcn token, --radius, --logo-*, and four more:
* --card-shadow (components/ui/card.jsx), --font-heading and --heading-case (style.css), --skin-bg (.skin-bg).
* Skins are static: no @keyframes, no animation. System font stacks only.
*
* Precedence while a skin is worn (top wins):
* badge, avatar border, banner frame, completed effect stay, and take the skin colors (--primary, --logo-*)
* page background effect, banner effect, item 10 art stay, and paint over the skin background
* skin colors, card style, radius, heading font, base pattern
* premium accent, free accent, corners overridden by the skin
* light/dark mode, "Reduce animations" always the viewer's; every skin has both modes
*
* Specificity is the whole precedence, because this file is imported before the accent blocks of style.css:
* light [data-skin][data-skin="x"] (0,2,0) beats [data-accent], [data-corners]
* dark .dark[data-skin][data-skin="x"], .dark [data-skin][data-skin="x"] (0,3,0) beats .dark[data-accent] (0,2,0)
* The light block also matches in dark, so a dark block holds only the tokens that change with the mode. A rule that
* uses another token (--card-shadow, --skin-bg with var(--primary)) sits in the block of that token, because custom
* properties resolve where they are declared. The "none" reset is a plain [data-skin="none"] (0,1,0): the accent and corners of the same wrapper, later
* in the source, still win over it. Do not add a rule with a higher specificity than these for accent or corners.
*/
/* A public profile of an owner without a skin: the shadcn tokens come from the plain theme blocks of style.css. */
[data-skin="none"] {
--font-heading: initial;
--heading-case: initial;
--logo-violet: initial;
--logo-blue: initial;
--logo-teal: initial;
}
.skin-bg {
background-image: var(--skin-bg);
background-attachment: fixed;
}
.skin-bg.bg-scroll {
background-attachment: scroll;
}
/* ---- Pastel: blush and plum, soft and round ---- */
[data-skin][data-skin="pastel"] {
--background: oklch(0.97 0.02 350);
--foreground: oklch(0.3 0.05 340);
--card: oklch(1 0.005 350);
--card-foreground: oklch(0.3 0.05 340);
--popover: oklch(1 0.005 350);
--popover-foreground: oklch(0.3 0.05 340);
--primary: oklch(0.55 0.17 5);
--primary-foreground: oklch(0.99 0.01 350);
--secondary: oklch(0.94 0.03 350);
--secondary-foreground: oklch(0.3 0.05 340);
--muted: oklch(0.94 0.03 350);
--muted-foreground: oklch(0.46 0.05 340);
--accent: oklch(0.93 0.06 165);
--accent-foreground: oklch(0.32 0.07 165);
--border: oklch(0.88 0.04 350);
--input: oklch(0.88 0.04 350);
--ring: oklch(0.65 0.15 5);
--radius: 1.25rem;
--logo-violet: #f472b6;
--logo-blue: #c084fc;
--logo-teal: #5eead4;
--card-shadow: 0 8px 28px -8px rgb(190 90 130 / 0.28);
--font-heading: ui-rounded, "SF Pro Rounded", "Inter Variable", sans-serif;
--heading-case: none;
--skin-bg:
radial-gradient(40rem 30rem at 0% 0%, rgb(249 168 212 / 0.35), transparent 70%),
radial-gradient(36rem 28rem at 100% 100%, rgb(134 239 172 / 0.3), transparent 70%);
}
.dark[data-skin][data-skin="pastel"],
.dark [data-skin][data-skin="pastel"] {
--background: oklch(0.2 0.04 330);
--foreground: oklch(0.95 0.02 350);
--card: oklch(0.25 0.05 330);
--card-foreground: oklch(0.95 0.02 350);
--popover: oklch(0.25 0.05 330);
--popover-foreground: oklch(0.95 0.02 350);
--primary: oklch(0.75 0.13 5);
--primary-foreground: oklch(0.2 0.04 330);
--secondary: oklch(0.31 0.05 330);
--secondary-foreground: oklch(0.95 0.02 350);
--muted: oklch(0.31 0.05 330);
--muted-foreground: oklch(0.76 0.04 340);
--accent: oklch(0.35 0.07 165);
--accent-foreground: oklch(0.93 0.05 165);
--border: oklch(0.4 0.05 335);
--input: oklch(0.4 0.05 335);
--ring: oklch(0.75 0.13 5);
--card-shadow: 0 8px 28px -8px rgb(0 0 0 / 0.5);
--skin-bg:
radial-gradient(40rem 30rem at 0% 0%, rgb(244 114 182 / 0.18), transparent 70%),
radial-gradient(36rem 28rem at 100% 100%, rgb(94 234 212 / 0.14), transparent 70%);
}
/* ---- Retro CRT: amber ink on paper, phosphor green on black, scanlines ---- */
[data-skin][data-skin="retro-crt"] {
--background: oklch(0.95 0.03 85);
--foreground: oklch(0.32 0.07 70);
--card: oklch(0.975 0.02 85);
--card-foreground: oklch(0.32 0.07 70);
--popover: oklch(0.975 0.02 85);
--popover-foreground: oklch(0.32 0.07 70);
--primary: oklch(0.5 0.12 65);
--primary-foreground: oklch(0.97 0.02 85);
--secondary: oklch(0.91 0.04 85);
--secondary-foreground: oklch(0.32 0.07 70);
--muted: oklch(0.91 0.04 85);
--muted-foreground: oklch(0.46 0.07 70);
--accent: oklch(0.88 0.08 80);
--accent-foreground: oklch(0.3 0.07 70);
--border: oklch(0.78 0.07 75);
--input: oklch(0.78 0.07 75);
--ring: oklch(0.6 0.14 70);
--radius: 0.25rem;
--logo-violet: #d97706;
--logo-blue: #b45309;
--logo-teal: #ca8a04;
--card-shadow: inset 0 0 18px rgb(180 110 20 / 0.18);
--font-heading: ui-monospace, "Cascadia Mono", Consolas, Menlo, monospace;
--heading-case: uppercase;
--skin-bg:
repeating-linear-gradient(0deg, rgb(120 70 10 / 0.07) 0 1px, transparent 1px 3px),
radial-gradient(ellipse at center, transparent 55%, rgb(120 70 10 / 0.18));
}
.dark[data-skin][data-skin="retro-crt"],
.dark [data-skin][data-skin="retro-crt"] {
--background: oklch(0.15 0.02 150);
--foreground: oklch(0.85 0.19 145);
--card: oklch(0.19 0.03 150);
--card-foreground: oklch(0.85 0.19 145);
--popover: oklch(0.19 0.03 150);
--popover-foreground: oklch(0.85 0.19 145);
--primary: oklch(0.82 0.2 145);
--primary-foreground: oklch(0.15 0.03 150);
--secondary: oklch(0.25 0.05 150);
--secondary-foreground: oklch(0.85 0.19 145);
--muted: oklch(0.25 0.05 150);
--muted-foreground: oklch(0.7 0.14 145);
--accent: oklch(0.3 0.08 150);
--accent-foreground: oklch(0.9 0.15 145);
--border: oklch(0.45 0.12 145);
--input: oklch(0.45 0.12 145);
--ring: oklch(0.82 0.2 145);
--logo-violet: #4ade80;
--logo-blue: #86efac;
--logo-teal: #22c55e;
--card-shadow: inset 0 0 18px rgb(74 222 128 / 0.14);
--skin-bg:
repeating-linear-gradient(0deg, rgb(0 0 0 / 0.28) 0 1px, transparent 1px 3px),
radial-gradient(ellipse at center, transparent 50%, rgb(0 0 0 / 0.5));
}
/* ---- Cyberpunk: neon yellow and cyan on violet black, a faint grid ---- */
[data-skin][data-skin="cyberpunk"] {
--background: oklch(0.97 0.05 100);
--foreground: oklch(0.22 0.06 300);
--card: oklch(0.99 0.03 100);
--card-foreground: oklch(0.22 0.06 300);
--popover: oklch(0.99 0.03 100);
--popover-foreground: oklch(0.22 0.06 300);
--primary: oklch(0.5 0.24 350);
--primary-foreground: oklch(0.99 0.01 100);
--secondary: oklch(0.93 0.06 100);
--secondary-foreground: oklch(0.22 0.06 300);
--muted: oklch(0.93 0.06 100);
--muted-foreground: oklch(0.42 0.07 300);
--accent: oklch(0.85 0.1 200);
--accent-foreground: oklch(0.25 0.07 220);
--border: oklch(0.6 0.12 200);
--input: oklch(0.6 0.12 200);
--ring: oklch(0.6 0.2 350);
--radius: 0.125rem;
--logo-violet: #db2777;
--logo-blue: #06b6d4;
--logo-teal: #eab308;
--card-shadow: 0 0 0 1px oklch(0.6 0.12 200), 0 0 14px -2px oklch(0.7 0.12 200 / 0.5);
--font-heading: ui-monospace, "Cascadia Mono", Consolas, Menlo, monospace;
--heading-case: uppercase;
--skin-bg:
repeating-linear-gradient(0deg, rgb(6 182 212 / 0.12) 0 1px, transparent 1px 40px),
repeating-linear-gradient(90deg, rgb(6 182 212 / 0.12) 0 1px, transparent 1px 40px);
}
.dark[data-skin][data-skin="cyberpunk"],
.dark [data-skin][data-skin="cyberpunk"] {
--background: oklch(0.15 0.04 300);
--foreground: oklch(0.95 0.03 100);
--card: oklch(0.2 0.05 300);
--card-foreground: oklch(0.95 0.03 100);
--popover: oklch(0.2 0.05 300);
--popover-foreground: oklch(0.95 0.03 100);
--primary: oklch(0.92 0.18 105);
--primary-foreground: oklch(0.18 0.04 300);
--secondary: oklch(0.27 0.06 300);
--secondary-foreground: oklch(0.95 0.03 100);
--muted: oklch(0.27 0.06 300);
--muted-foreground: oklch(0.78 0.08 200);
--accent: oklch(0.8 0.13 200);
--accent-foreground: oklch(0.18 0.04 220);
--border: oklch(0.6 0.12 200);
--input: oklch(0.6 0.12 200);
--ring: oklch(0.8 0.13 200);
--logo-violet: #facc15;
--logo-blue: #22d3ee;
--logo-teal: #e879f9;
--card-shadow: 0 0 0 1px oklch(0.6 0.12 200), 0 0 18px -2px oklch(0.8 0.13 200 / 0.45);
--skin-bg:
repeating-linear-gradient(0deg, rgb(34 211 238 / 0.07) 0 1px, transparent 1px 40px),
repeating-linear-gradient(90deg, rgb(34 211 238 / 0.07) 0 1px, transparent 1px 40px);
}
/* ---- Steam: the dark blue of a game store (colors only, no logo or art) ---- */
[data-skin][data-skin="steam"] {
--background: #e5edf3;
--foreground: #1b2838;
--card: #ffffff;
--card-foreground: #1b2838;
--popover: #ffffff;
--popover-foreground: #1b2838;
--primary: #1a6fa8;
--primary-foreground: #ffffff;
--secondary: #d3e0ea;
--secondary-foreground: #1b2838;
--muted: #d3e0ea;
--muted-foreground: #4a5e70;
--accent: #cfe3f2;
--accent-foreground: #124e78;
--border: #b5c7d6;
--input: #b5c7d6;
--ring: #66c0f4;
--radius: 0.125rem;
--logo-violet: #1a6fa8;
--logo-blue: #66c0f4;
--logo-teal: #2a475e;
--card-shadow: 0 2px 6px rgb(27 40 56 / 0.25);
--font-heading: "Inter Variable", ui-sans-serif, system-ui, sans-serif;
--heading-case: uppercase;
--skin-bg: radial-gradient(60rem 28rem at 50% 0%, rgb(42 71 94 / 0.22), transparent 70%);
}
.dark[data-skin][data-skin="steam"],
.dark [data-skin][data-skin="steam"] {
--background: #1b2838;
--foreground: #c7d5e0;
--card: #16202d;
--card-foreground: #c7d5e0;
--popover: #16202d;
--popover-foreground: #c7d5e0;
--primary: #66c0f4;
--primary-foreground: #0e1822;
--secondary: #2a475e;
--secondary-foreground: #c7d5e0;
--muted: #2a475e;
--muted-foreground: #a9bccb;
--accent: #2a475e;
--accent-foreground: #8fd0f7;
--border: #3d5a73;
--input: #3d5a73;
--ring: #66c0f4;
--logo-violet: #66c0f4;
--logo-blue: #c7d5e0;
--logo-teal: #4b8fb8;
--card-shadow: 0 4px 12px rgb(0 0 0 / 0.5);
--skin-bg: radial-gradient(60rem 28rem at 50% 0%, #2a475e, transparent 70%);
}
+13 -2
View File
@@ -1,12 +1,14 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "./effects.css";
@import "./skins.css";
@import "@fontsource-variable/inter";
@custom-variant dark (&:is(.dark *));
/* shadcn/ui theme tokens: zinc surfaces, violet accent. */
:root {
:root,
[data-skin="none"] {
--radius: 0.75rem;
--background: oklch(0.985 0 0);
--foreground: oklch(0.141 0.005 285.823);
@@ -26,9 +28,12 @@
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.606 0.25 292.717);
--card-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--skin-bg: none;
}
.dark {
.dark,
.dark [data-skin="none"] {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
@@ -277,6 +282,12 @@
option {
@apply bg-popover text-popover-foreground;
}
/* Skins (skins.css) set both; without a skin they change nothing. */
h1, h2, h3 {
font-family: var(--font-heading, inherit);
text-transform: var(--heading-case, none);
}
}
/* Radix menus and selects lock the page scroll and add a right margin as wide as the scrollbar, measured as
+6 -3
View File
@@ -39,9 +39,12 @@ export const readCachedAppearance = () => {
export const cacheAppearance = (appearance) => storage.set(STORAGE_KEY, JSON.stringify(appearance));
// accentOverride: a premium accent worn from the shop (Core owns it), which wins over the saved one.
export const applyAppearance = (appearance, accentOverride = null, root = document.documentElement) => {
root.dataset.accent = accentOverride ?? appearance.accent;
// accent: a premium accent worn from the shop (Core owns it), which wins over the saved one. skin: the skin worn;
// skins.css lets it win over accent and corners.
export const applyAppearance = (appearance, {accent = null, skin = null} = {}, root = document.documentElement) => {
root.dataset.accent = accent ?? appearance.accent;
if (skin) root.dataset.skin = skin;
else delete root.dataset.skin;
root.dataset.corners = appearance.corners;
root.toggleAttribute('data-reduce-motion', appearance.reduceMotion === true);
};
+15 -4
View File
@@ -45,7 +45,7 @@ describe('applyAppearance', () => {
it('sets the presets as data attributes for the CSS', () => {
const root = document.createElement('html');
applyAppearance({accent: 'orange', corners: 'round', wideLayout: true, backgroundAppid: 620}, null, root);
applyAppearance({accent: 'orange', corners: 'round', wideLayout: true, backgroundAppid: 620}, {}, root);
expect(root.dataset.accent).toBe('orange');
expect(root.dataset.corners).toBe('round');
@@ -54,10 +54,21 @@ describe('applyAppearance', () => {
it('lets a worn premium accent win over the saved one', () => {
const root = document.createElement('html');
applyAppearance({accent: 'orange', corners: 'round'}, 'gold', root);
applyAppearance({accent: 'orange', corners: 'round'}, {accent: 'gold'}, root);
expect(root.dataset.accent).toBe('gold');
});
it('sets the worn skin and removes it again', () => {
const root = document.createElement('html');
applyAppearance({accent: 'orange', corners: 'round'}, {accent: 'gold', skin: 'cyberpunk'}, root);
expect(root.dataset.skin).toBe('cyberpunk');
expect(root.dataset.accent).toBe('gold');
applyAppearance({accent: 'orange', corners: 'round'}, {}, root);
expect(root.dataset.skin).toBeUndefined();
});
});
describe('reduce motion', () => {
@@ -71,10 +82,10 @@ describe('reduce motion', () => {
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);
applyAppearance({accent: 'blue', corners: 'default', reduceMotion: true}, {}, root);
expect(root.hasAttribute('data-reduce-motion')).toBe(true);
applyAppearance({accent: 'blue', corners: 'default', reduceMotion: false}, null, root);
applyAppearance({accent: 'blue', corners: 'default', reduceMotion: false}, {}, root);
expect(root.hasAttribute('data-reduce-motion')).toBe(false);
});
+41 -2
View File
@@ -3,8 +3,9 @@ import {apiFetch} from './api.js';
const AUTH = () => import.meta.env.VITE_API_URL;
/**
* GET /api/hints: {results: [{id, textEn, textDe, kind, startsAt, endsAt}]}, the running hints the user has not
* closed for good, newest first.
* GET /api/hints: {results: [{id, textEn, textDe, kind, startsAt, endsAt, emptyLibraryOnly}], automatic: [keys]}.
* results: the running hints for this user that are not closed for good, newest first (emptyLibraryOnly: show only
* with an empty library, checked here). automatic: the automatic hints that are on and not hidden for this user.
*/
export const fetchHints = () => apiFetch(`${AUTH()}/api/hints`);
@@ -31,3 +32,41 @@ export const createHint = (hint) =>
export const endHint = (hintId) => apiFetch(`${AUTH()}/api/admin/hints/${hintId}/end`, {method: 'PUT'});
export const hintText = (hint, language) => (language?.startsWith('de') && hint.textDe ? hint.textDe : hint.textEn);
/**
* The automatic hints in the order they show.
*/
export const AUTO_HINTS = ['NO_STEAM', 'EMPTY_LIBRARY', 'LINK_QUEUE', 'PILE_GREW'];
/**
* 204 or 404 HINT_NOT_FOUND: hide the automatic hint for 7 days.
*/
export const snoozeAutoHint = (key) => apiFetch(`${AUTH()}/api/hints/auto/${key}/snooze`, {method: 'PUT'});
/**
* 204 or 404 HINT_NOT_FOUND: hide the automatic hint for good.
*/
export const hideAutoHint = (key) => apiFetch(`${AUTH()}/api/hints/auto/${key}/done`, {method: 'PUT'});
/**
* PLATFORM_ADMIN only: 204, or 404 HINT_NOT_FOUND.
*/
export const setAutoHintEnabled = (key, enabled) =>
apiFetch(`${AUTH()}/api/admin/hints/auto/${key}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({enabled}),
});
/**
* How much the pile grew since the end of last month (else since the first day of this month), from the TREND group of the stats. Uses Core's
* dates, not the browser clock.
*/
export const pileGrowth = (trend) => {
const days = trend?.days ?? [];
const last = days.at(-1);
const month = last?.day.slice(0, 7);
// The snapshot of a day is written at its end, so only last month's last row excludes the first day's additions.
const first = days.findLast((d) => d.day.slice(0, 7) < month) ?? days.find((d) => d.day.slice(0, 7) === month);
return last && first !== last ? last.backlogGames - first.backlogGames : 0;
};
+22 -2
View File
@@ -1,11 +1,11 @@
import {Swords, Trophy} from 'lucide-react';
import {Flame, Flag, Gem, Swords, TrendingUp, Trophy} from 'lucide-react';
import {apiFetch, request} from './api.js';
import {storage} from './storage.js';
const REWARDS_URL = `${import.meta.env.VITE_CORE_API_URL}/api/rewards`;
const JSON_HEADERS = {'Content-Type': 'application/json'};
export const SLOTS = ['AVATAR_BORDER', 'BADGE', 'ACCENT', 'BANNER_FRAME', 'COMPLETED_EFFECT', 'PAGE_BACKGROUND', 'BANNER_BACKGROUND'];
export const SLOTS = ['AVATAR_BORDER', 'BADGE', 'ACCENT', 'BANNER_FRAME', 'COMPLETED_EFFECT', 'PAGE_BACKGROUND', 'BANNER_BACKGROUND', 'SKIN'];
// 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.
@@ -40,11 +40,31 @@ export const LOOKS = {
BN_GRID: {effect: 'grid'},
BN_WAVES: {effect: 'waves'},
BN_BOKEH: {effect: 'bokeh'},
// Unlock-only items (Core: earned by progress, never sold).
BADGE_FINISH_10: {Icon: Flag, className: 'bg-emerald-500/15 text-emerald-500'},
BORDER_FINISH_25: {className: 'ring-4 ring-slate-300 ring-offset-2 ring-offset-background'},
FRAME_FINISH_50: {className: 'ring-2 ring-emerald-400 ring-offset-2 ring-offset-background shadow-[0_0_14px] shadow-emerald-400/50'},
BADGE_PERFECT_5: {Icon: Gem, className: 'bg-cyan-500/15 text-cyan-500'},
FX_PLATINUM: {cover: 'fx fx-cover-platinum outline outline-2 -outline-offset-2 outline-slate-200', detail: 'fx-detail-platinum'},
BADGE_STREAK_4: {Icon: Flame, className: 'bg-orange-500/15 text-orange-500'},
BORDER_STREAK_12: {className: 'ring-4 ring-orange-500 shadow-[0_0_14px] shadow-orange-500/60'},
FRAME_PILE_DOWN_3: {className: 'ring-2 ring-teal-400 ring-offset-2 ring-offset-background'},
BADGE_PLAY_MORE_1: {Icon: TrendingUp, className: 'bg-sky-500/15 text-sky-500'},
BORDER_PLAY_MORE_3: {className: 'ring-4 ring-sky-400 ring-offset-2 ring-offset-background'},
BN_EQUALIZER: {effect: 'equalizer'},
// Skins (skins.css): full themes, set as data-skin.
SKIN_PASTEL: {skin: 'pastel'},
SKIN_RETRO_CRT: {skin: 'retro-crt'},
SKIN_CYBERPUNK: {skin: 'cyberpunk'},
SKIN_STEAM: {skin: 'steam'},
};
/** The premium accent worn, or null (then the appearance accent applies). */
export const equippedAccent = (equipped) => LOOKS[equipped?.ACCENT]?.accent ?? null;
/** The skin worn (a data-skin value of skins.css), or null. */
export const equippedSkin = (equipped) => LOOKS[equipped?.SKIN]?.skin ?? null;
/** The classes of the item worn in a slot, or undefined. */
export const lookClass = (equipped, slot) => LOOKS[equipped?.[slot]]?.className;
+10 -1
View File
@@ -1,5 +1,5 @@
import {describe, expect, it} from 'vitest';
import {backgroundEffect, bannerEffect, coverEffect, detailEffect, LOOKS} from './rewards.js';
import {backgroundEffect, bannerEffect, coverEffect, detailEffect, equippedSkin, LOOKS} from './rewards.js';
describe('effects worn', () => {
const equipped = {COMPLETED_EFFECT: 'FX_GOLD_SHINE', PAGE_BACKGROUND: 'BG_STARFIELD', BANNER_BACKGROUND: 'BN_WAVES'};
@@ -24,3 +24,12 @@ describe('effects worn', () => {
expect(LOOKS.BG_GRID.effect).toBe(LOOKS.BN_GRID.effect);
});
});
describe('equippedSkin', () => {
it('gives the data-skin value of a known skin and null otherwise', () => {
expect(equippedSkin({SKIN: 'SKIN_RETRO_CRT'})).toBe('retro-crt');
expect(equippedSkin({SKIN: 'SKIN_FUTURE'})).toBeNull();
expect(equippedSkin({})).toBeNull();
expect(equippedSkin(undefined)).toBeNull();
});
});